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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1079 -630
- data/README.md +612 -17
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3706 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +188 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +47 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +27 -7
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- 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
|
|
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
|
|
194
|
+
# --- the count predicates: length: (#226) and checked: (#319) ---------
|
|
184
195
|
|
|
185
|
-
# A Hash value names a STRUCTURAL predicate
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
def
|
|
191
|
-
value =
|
|
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
|
-
|
|
195
|
-
[{ "field" => name, "
|
|
196
|
-
when Range then
|
|
197
|
-
else
|
|
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
|
|
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
|
-
|
|
205
|
-
[first, last].compact.each {
|
|
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, "
|
|
221
|
+
terms << { "field" => name, "#{prefix}_gte" => first } unless first.nil?
|
|
209
222
|
if last
|
|
210
|
-
terms << { "field" => name,
|
|
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
|
|
216
|
-
# negates to the outside disjunction (
|
|
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).
|
|
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
|
|
222
|
-
value =
|
|
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
|
-
|
|
226
|
-
[[{ "field" => name, "
|
|
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
|
-
|
|
231
|
-
[first, last].compact.each {
|
|
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, "
|
|
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, "
|
|
237
|
-
else [{ "field" => name, "
|
|
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
|
|
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
|
|
245
|
-
# that must fail at render, never silently in the
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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}
|
|
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
|
|
280
|
+
def raise_count_kind(name, kind, value)
|
|
265
281
|
raise ArgumentError,
|
|
266
|
-
"reactive_show: #{name.inspect}
|
|
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
|
|
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
|
|
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
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
when "
|
|
338
|
-
when "
|
|
339
|
-
when "
|
|
340
|
-
when "
|
|
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
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
-
|
|
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"
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -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);
|
|
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
|
-
|
|
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]"
|