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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1097 -630
- data/README.md +638 -16
- 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 +1511 -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/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
- 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 +3690 -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 +104 -28
- 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 +71 -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 +64 -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 +61 -12
- 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/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- 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 +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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
177
|
-
# the
|
|
178
|
-
#
|
|
179
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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).
|
|
@@ -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
|
-
|
|
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
|