phlex-reactive 0.4.8 → 0.9.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 +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Validates a phlex-reactive install and reports ✓/✗/? per check with a fix
|
|
6
|
+
# for each failure (issue #106). Five closed issues (#3 boot/eager-load, #26
|
|
7
|
+
# route shadowing, #42 lost request, #48 unregistered controller, #57
|
|
8
|
+
# importmap 404) were pure integration papercuts that only surfaced AFTER
|
|
9
|
+
# something already broke. The doctor turns "nothing happens, why?" into an
|
|
10
|
+
# actionable checklist you run before/after setup:
|
|
11
|
+
#
|
|
12
|
+
# bin/rails phlex_reactive:doctor
|
|
13
|
+
#
|
|
14
|
+
# Every check is a small object answering [status, message, fix]. It is
|
|
15
|
+
# READ-ONLY — it never mounts a component, mutates state, or touches the
|
|
16
|
+
# default-deny boundary; the worst it does is a throwaway sign→verify round
|
|
17
|
+
# trip and (for the component checks) iterate the loaded Streamable registry.
|
|
18
|
+
class Doctor
|
|
19
|
+
# The result of one check: a status (:ok/:fail/:unknown), a human message,
|
|
20
|
+
# and (on anything but :ok) a fix line telling the adopter what to do. A
|
|
21
|
+
# plain value object (not Data) so it takes positional status/message plus
|
|
22
|
+
# keyword name:/fix: — the shape the check builders and specs construct.
|
|
23
|
+
class Check
|
|
24
|
+
attr_reader :name, :status, :message, :fix
|
|
25
|
+
|
|
26
|
+
def initialize(status, message, name: nil, fix: nil)
|
|
27
|
+
@name = name
|
|
28
|
+
@status = status
|
|
29
|
+
@message = message
|
|
30
|
+
@fix = fix
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ok? = status == :ok
|
|
34
|
+
def fail? = status == :fail
|
|
35
|
+
def unknown? = status == :unknown
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Glyphs are plain ASCII-safe Unicode with NO ANSI color, so CI/log capture
|
|
39
|
+
# reads cleanly (issue #106 acceptance: stable plain-text output).
|
|
40
|
+
GLYPHS = { ok: "✓", fail: "✗", unknown: "?" }.freeze
|
|
41
|
+
|
|
42
|
+
# The entrypoint the rake task calls: eager-load so the component registry
|
|
43
|
+
# is populated, print the report, and return TRUE when nothing failed (an
|
|
44
|
+
# advisory `?` doesn't count) so a caller can gate its exit code on it.
|
|
45
|
+
# (Not a predicate name — this is the imperative "run + report" action that
|
|
46
|
+
# happens to return a success boolean; `io` is the conventional stream name.)
|
|
47
|
+
def self.run(io: $stdout) # rubocop:disable Naming/PredicateMethod,Naming/MethodParameterName
|
|
48
|
+
::Rails.application.eager_load! if defined?(::Rails) && ::Rails.application
|
|
49
|
+
doctor = new
|
|
50
|
+
io.puts(doctor.report)
|
|
51
|
+
!doctor.failures?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Run every check and return the ordered list of Check objects, memoized so
|
|
55
|
+
# report + failures? share one pass. The caller (or Doctor.run) is
|
|
56
|
+
# responsible for eager_load! so component classes are in the registry —
|
|
57
|
+
# the component checks are empty otherwise.
|
|
58
|
+
def checks
|
|
59
|
+
@checks ||= build_checks
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# True when any check FAILED (a hard ✗). Advisory `?` lines are not
|
|
63
|
+
# failures — a Phlex-layout app legitimately can't verify csrf that way.
|
|
64
|
+
def failures?
|
|
65
|
+
checks.any?(&:fail?)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_checks
|
|
69
|
+
components = registered_components
|
|
70
|
+
[
|
|
71
|
+
route_check,
|
|
72
|
+
stimulus_check,
|
|
73
|
+
csrf_check,
|
|
74
|
+
verifier_check,
|
|
75
|
+
base_controller_check,
|
|
76
|
+
action_check(components),
|
|
77
|
+
id_check(components)
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- individual checks ------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# Does POST <action_path> resolve to the gem's ActionsController? A host
|
|
84
|
+
# catch-all route shadows it otherwise (issue #26). Reuses the shipped
|
|
85
|
+
# guard verbatim — it already handles routes-not-yet-drawn.
|
|
86
|
+
def route_check
|
|
87
|
+
path = Phlex::Reactive.action_path
|
|
88
|
+
if Phlex::Reactive.action_route_ok?(path)
|
|
89
|
+
Check.new(:ok, "POST #{path} routes to #{Doctor.actions_controller}", name: :route)
|
|
90
|
+
else
|
|
91
|
+
Check.new(:fail, "POST #{path} does not resolve to #{Doctor.actions_controller}", name: :route,
|
|
92
|
+
fix: "A host catch-all route (match \"*path\", ...) likely shadows it. Exempt " \
|
|
93
|
+
"#{path.delete_prefix("/")} from the catch-all, or set Phlex::Reactive.action_path " \
|
|
94
|
+
"to an unshadowed path.")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Is the generic `reactive` controller registered in a Stimulus entrypoint
|
|
99
|
+
# (issue #48)? Grep the candidate entrypoints for the register line; when
|
|
100
|
+
# importmap is present, additionally verify the pin resolves.
|
|
101
|
+
def stimulus_check
|
|
102
|
+
entrypoint = stimulus_registration_files.find { registers_reactive?(it) }
|
|
103
|
+
return stimulus_missing_check unless entrypoint
|
|
104
|
+
|
|
105
|
+
if importmap_pin_broken?
|
|
106
|
+
return Check.new(:fail, "reactive registered in #{relative(entrypoint)}, but the importmap " \
|
|
107
|
+
"pin for phlex/reactive/reactive_controller is missing", name: :stimulus,
|
|
108
|
+
fix: "The engine auto-pins it; if you overrode config/importmap.rb, add:\n " \
|
|
109
|
+
"pin \"phlex/reactive/reactive_controller\"")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Check.new(:ok, "reactive controller registered in #{relative(entrypoint)}", name: :stimulus)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ADVISORY only (issue #106): grep ERB layouts AND Phlex layout files for a
|
|
116
|
+
# csrf_meta_tags reference. A hard fail would false-flag Phlex-layout apps
|
|
117
|
+
# (this gem's core audience), so a miss is :unknown, never :fail.
|
|
118
|
+
def csrf_check
|
|
119
|
+
if csrf_meta_referenced?
|
|
120
|
+
Check.new(:ok, "csrf_meta_tags found in a layout", name: :csrf)
|
|
121
|
+
else
|
|
122
|
+
Check.new(:unknown, "could not verify csrf_meta_tags in a layout", name: :csrf,
|
|
123
|
+
fix: "Confirm your layout renders csrf_meta_tags (ERB: <%= csrf_meta_tags %>; " \
|
|
124
|
+
"Phlex: render Phlex::Rails::Helpers::CSRFMetaTags or emit the meta tags) — " \
|
|
125
|
+
"the client reads the CSRF token from <meta name=\"csrf-token\">.")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A throwaway sign→verify round trip proves the verifier is configured and
|
|
130
|
+
# the key round-trips (a bad secret_key_base or a purpose mismatch fails).
|
|
131
|
+
# verify legitimately stamps the version key "v" (issue #111), so we check
|
|
132
|
+
# the original entries survived rather than exact equality.
|
|
133
|
+
def verifier_check
|
|
134
|
+
payload = { "c" => "Phlex::Reactive::Doctor", "probe" => true }
|
|
135
|
+
roundtripped = Phlex::Reactive.verify(Phlex::Reactive.sign(payload))
|
|
136
|
+
if roundtripped && payload.all? { |k, v| roundtripped[k] == v }
|
|
137
|
+
Check.new(:ok, "identity verifier signs and verifies", name: :verifier)
|
|
138
|
+
else
|
|
139
|
+
Check.new(:fail, "identity verifier did not round-trip a probe payload", name: :verifier,
|
|
140
|
+
fix: "Check secret_key_base is set, or configure a dedicated " \
|
|
141
|
+
"Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(key).")
|
|
142
|
+
end
|
|
143
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
144
|
+
Check.new(:fail, "identity verifier raised: #{e.message}", name: :verifier,
|
|
145
|
+
fix: "Set secret_key_base, or configure Phlex::Reactive.verifier explicitly.")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Does Phlex::Reactive.base_controller_name constantize (issue #48-adjacent)?
|
|
149
|
+
def base_controller_check
|
|
150
|
+
name = Phlex::Reactive.base_controller_name
|
|
151
|
+
klass = Phlex::Reactive.base_controller
|
|
152
|
+
Check.new(:ok, "base_controller_name #{name} constantizes to #{klass}", name: :base_controller)
|
|
153
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
154
|
+
Check.new(:fail, "base_controller_name #{Phlex::Reactive.base_controller_name.inspect} " \
|
|
155
|
+
"does not constantize (#{e.class})", name: :base_controller,
|
|
156
|
+
fix: "Set Phlex::Reactive.base_controller_name to a controller that exists " \
|
|
157
|
+
"(e.g. \"ApplicationController\").")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Every declared `action :name` must have a public instance method (mirrors
|
|
161
|
+
# the endpoint's public_send dispatch — a missing one 500s at click).
|
|
162
|
+
def action_check(components)
|
|
163
|
+
missing = components.flat_map { missing_action_methods(it) }
|
|
164
|
+
return Check.new(:ok, "every declared action has a public method", name: :actions) if missing.empty?
|
|
165
|
+
|
|
166
|
+
Check.new(:fail, "declared actions with no matching public method: #{missing.join(", ")}", name: :actions,
|
|
167
|
+
fix: "Define a public method for each, or remove the `action :name` declaration.")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Flag a class that would raise NotImplementedError in #id at render: it
|
|
171
|
+
# inherits Streamable's default #id AND is NOT record-backed. A record-backed
|
|
172
|
+
# class on the default is FINE — that default shipped in #81.
|
|
173
|
+
def id_check(components)
|
|
174
|
+
offenders = components.select { default_id_without_record?(it) }.map(&:name)
|
|
175
|
+
return Check.new(:ok, "every component resolves a stable #id", name: :ids) if offenders.empty?
|
|
176
|
+
|
|
177
|
+
Check.new(:fail, "state-backed components with no #id (render raises NotImplementedError): " \
|
|
178
|
+
"#{offenders.join(", ")}", name: :ids,
|
|
179
|
+
fix: "Add `def id = \"my-thing\"` to each — a state-backed component has no record to " \
|
|
180
|
+
"derive a default id from.")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- rendering --------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
# The full plain-text report: a line per check, plus an indented fix under
|
|
186
|
+
# each non-passing one. No ANSI color (clean CI/log capture).
|
|
187
|
+
def report
|
|
188
|
+
lines = ["phlex-reactive doctor", ""]
|
|
189
|
+
results = checks
|
|
190
|
+
results.each { lines << render_check(it) }
|
|
191
|
+
lines << ""
|
|
192
|
+
lines << summary_line(results)
|
|
193
|
+
lines.join("\n")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# One check as "✓/✗/? message" plus an indented "→ fix" when it isn't ok.
|
|
197
|
+
def render_check(check)
|
|
198
|
+
line = "#{GLYPHS.fetch(check.status)} #{check.message}"
|
|
199
|
+
line += "\n → #{check.fix}" if check.fix && !check.ok?
|
|
200
|
+
line
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.actions_controller
|
|
204
|
+
"phlex/reactive/actions"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# A one-line tally: how many passed, failed, and are advisory/unknown.
|
|
210
|
+
def summary_line(results)
|
|
211
|
+
passed = results.count(&:ok?)
|
|
212
|
+
failed = results.count(&:fail?)
|
|
213
|
+
advisory = results.count(&:unknown?)
|
|
214
|
+
parts = ["#{passed} passed"]
|
|
215
|
+
parts << "#{failed} failed" if failed.positive?
|
|
216
|
+
parts << "#{advisory} advisory" if advisory.positive?
|
|
217
|
+
parts.join(", ")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The registry filtered to real, CONSTANT-RESOLVABLE reactive components.
|
|
221
|
+
# A class is only invokable by the endpoint if its own name round-trips
|
|
222
|
+
# through safe_constantize (that's exactly how ActionsController#resolve_component
|
|
223
|
+
# rebuilds it from the token). So we validate only classes where
|
|
224
|
+
# name.safe_constantize is the class itself — which also excludes anonymous
|
|
225
|
+
# classes (name nil) and test fixtures that fake `def self.name` without a
|
|
226
|
+
# matching constant, keeping the whole-app scan honest.
|
|
227
|
+
def registered_components
|
|
228
|
+
Phlex::Reactive::Streamable.registered_classes.select { constant_backed_component?(it) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def constant_backed_component?(klass)
|
|
232
|
+
reactive_component?(klass) && klass.name && klass.name.safe_constantize.equal?(klass)
|
|
233
|
+
rescue StandardError
|
|
234
|
+
false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def reactive_component?(klass)
|
|
238
|
+
klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
|
|
239
|
+
rescue StandardError
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# "Klass#action" for every declared action on `klass` that has no public
|
|
244
|
+
# method to dispatch to. Kept as its own method (not a nested block) so the
|
|
245
|
+
# class is a named method arg, not a shadowed `it`.
|
|
246
|
+
def missing_action_methods(klass)
|
|
247
|
+
klass.reactive_actions.keys
|
|
248
|
+
.reject { klass.public_method_defined?(it) }
|
|
249
|
+
.map { "#{klass}##{it}" }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# True when the class still uses Streamable's default #id AND has no record
|
|
253
|
+
# to back it (so the default raises). owner == Streamable means no override.
|
|
254
|
+
def default_id_without_record?(klass)
|
|
255
|
+
klass.instance_method(:id).owner == Phlex::Reactive::Streamable &&
|
|
256
|
+
!(klass.respond_to?(:reactive_record_key) && klass.reactive_record_key)
|
|
257
|
+
rescue StandardError
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def stimulus_missing_check
|
|
262
|
+
Check.new(:fail, "the reactive controller is not registered in any Stimulus entrypoint", name: :stimulus,
|
|
263
|
+
fix: "Add to your entrypoint (e.g. app/javascript/controllers/index.js):\n " \
|
|
264
|
+
"import ReactiveController from \"phlex/reactive/reactive_controller\"\n " \
|
|
265
|
+
"application.register(\"reactive\", ReactiveController)\n" \
|
|
266
|
+
"or re-run: bin/rails generate phlex:reactive:install")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Files that may hold the register line: the JS entrypoint candidates,
|
|
270
|
+
# importmap-style AND esbuild/bun (issue #106) — controllers/index.js,
|
|
271
|
+
# controllers/application.js, application.js — PLUS ERB layouts, since a
|
|
272
|
+
# small/importmap app often registers inline in <head> rather than in a
|
|
273
|
+
# dedicated entrypoint file. Only existing files are returned.
|
|
274
|
+
def stimulus_registration_files
|
|
275
|
+
candidates = %w[
|
|
276
|
+
app/javascript/controllers/index.js
|
|
277
|
+
app/javascript/controllers/application.js
|
|
278
|
+
app/javascript/application.js
|
|
279
|
+
].map { app_path(it) }
|
|
280
|
+
candidates += ::Dir.glob(app_path("app/views/layouts/**/*.erb"))
|
|
281
|
+
candidates.select { File.exist?(it) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def registers_reactive?(path)
|
|
285
|
+
File.read(path).include?('application.register("reactive", ReactiveController)')
|
|
286
|
+
rescue StandardError
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Only meaningful when importmap is in use. True when importmap is present
|
|
291
|
+
# but the reactive_controller pin is absent (the engine pins it, so this is
|
|
292
|
+
# really "someone overrode importmap.rb and dropped the pin").
|
|
293
|
+
def importmap_pin_broken?
|
|
294
|
+
return false unless defined?(::Importmap) && ::Rails.application.respond_to?(:importmap)
|
|
295
|
+
|
|
296
|
+
map = ::Rails.application.importmap
|
|
297
|
+
return false unless map
|
|
298
|
+
|
|
299
|
+
!map.packages.key?("phlex/reactive/reactive_controller")
|
|
300
|
+
rescue StandardError
|
|
301
|
+
false
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference.
|
|
305
|
+
def csrf_meta_referenced?
|
|
306
|
+
globs = %w[
|
|
307
|
+
app/views/**/*.erb
|
|
308
|
+
app/views/**/*.rb
|
|
309
|
+
app/components/**/*.rb
|
|
310
|
+
app/views/**/layout*.html*
|
|
311
|
+
].map { app_path(it) }
|
|
312
|
+
|
|
313
|
+
::Dir.glob(globs).any? do
|
|
314
|
+
File.read(it).include?("csrf_meta_tags")
|
|
315
|
+
rescue StandardError
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
rescue StandardError
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def app_path(relative)
|
|
323
|
+
::Rails.root.join(relative).to_s
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def relative(path)
|
|
327
|
+
return path unless defined?(::Rails) && ::Rails.root
|
|
328
|
+
|
|
329
|
+
path.delete_prefix("#{::Rails.root}/")
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -19,15 +19,21 @@ module Phlex
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# Make
|
|
23
|
-
# be
|
|
22
|
+
# Make the MINIFIED client build available to Propshaft/Sprockets so it can
|
|
23
|
+
# be fingerprinted, served, and pinned in importmap. The browser ships the
|
|
24
|
+
# minified twin (108 KB -> 22 KB for the controller; `rake build:js`), not
|
|
25
|
+
# the comment-dense source. The .min.js.map is precompiled too so devtools
|
|
26
|
+
# resolves the linked sourcemap back to the readable source on demand.
|
|
24
27
|
initializer "phlex_reactive.assets" do
|
|
25
28
|
if it.config.respond_to?(:assets)
|
|
26
29
|
it.config.assets.paths << root.join("app/javascript").to_s
|
|
27
30
|
it.config.assets.precompile += %w[
|
|
28
|
-
phlex/reactive/reactive_controller.js
|
|
29
|
-
phlex/reactive/
|
|
30
|
-
phlex/reactive/
|
|
31
|
+
phlex/reactive/reactive_controller.min.js
|
|
32
|
+
phlex/reactive/reactive_controller.min.js.map
|
|
33
|
+
phlex/reactive/confirm.min.js
|
|
34
|
+
phlex/reactive/confirm.min.js.map
|
|
35
|
+
phlex/reactive/compute.min.js
|
|
36
|
+
phlex/reactive/compute.min.js.map
|
|
31
37
|
]
|
|
32
38
|
end
|
|
33
39
|
end
|
|
@@ -39,7 +45,7 @@ module Phlex
|
|
|
39
45
|
if defined?(::Importmap::Map) && it.respond_to?(:importmap)
|
|
40
46
|
it.importmap.pin(
|
|
41
47
|
"phlex/reactive/reactive_controller",
|
|
42
|
-
to: "phlex/reactive/reactive_controller.js",
|
|
48
|
+
to: "phlex/reactive/reactive_controller.min.js",
|
|
43
49
|
preload: true
|
|
44
50
|
)
|
|
45
51
|
# The overridable confirm resolver (issue #55). reactive_controller.js
|
|
@@ -52,7 +58,7 @@ module Phlex
|
|
|
52
58
|
# "phlex/reactive/confirm"` both resolve through the import map.
|
|
53
59
|
it.importmap.pin(
|
|
54
60
|
"phlex/reactive/confirm",
|
|
55
|
-
to: "phlex/reactive/confirm.js",
|
|
61
|
+
to: "phlex/reactive/confirm.min.js",
|
|
56
62
|
preload: true
|
|
57
63
|
)
|
|
58
64
|
# The client-side compute (data-binding) registry behind
|
|
@@ -62,7 +68,7 @@ module Phlex
|
|
|
62
68
|
# "phlex/reactive/compute"` — both resolve through this pin.
|
|
63
69
|
it.importmap.pin(
|
|
64
70
|
"phlex/reactive/compute",
|
|
65
|
-
to: "phlex/reactive/compute.js",
|
|
71
|
+
to: "phlex/reactive/compute.min.js",
|
|
66
72
|
preload: true
|
|
67
73
|
)
|
|
68
74
|
end
|
|
@@ -74,7 +80,7 @@ module Phlex
|
|
|
74
80
|
# fresh after boot. See Streamable.reset_all_view_contexts!.
|
|
75
81
|
config.to_prepare do
|
|
76
82
|
Phlex::Reactive::Streamable.reset_all_view_contexts!
|
|
77
|
-
Phlex::Reactive.
|
|
83
|
+
Phlex::Reactive.reset_stream_builder!
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
# Boot-time guard (issue #26): warn if the action path doesn't resolve to
|
|
@@ -84,6 +90,18 @@ module Phlex
|
|
|
84
90
|
# a one-line log pointing at the cause. No-op when the route is fine.
|
|
85
91
|
config.after_initialize do
|
|
86
92
|
Phlex::Reactive.warn_unless_action_route_mounted!
|
|
93
|
+
|
|
94
|
+
# Attach the opt-in LogSubscriber (issue #107) exactly once, only when
|
|
95
|
+
# the app enabled it. attach_to is idempotent-safe here because this runs
|
|
96
|
+
# once per boot; the events fire for APMs regardless of this flag.
|
|
97
|
+
Phlex::Reactive::LogSubscriber.attach_to(:phlex_reactive) if Phlex::Reactive.log_events
|
|
98
|
+
|
|
99
|
+
# Freeze the param-type registry (issue #109): custom types register in
|
|
100
|
+
# an initializer, which has run by now, so no further registration is
|
|
101
|
+
# accepted. A schema referencing a type is validated at declaration; the
|
|
102
|
+
# frozen registry makes runtime registration a loud error rather than a
|
|
103
|
+
# never-validated type. Idempotent.
|
|
104
|
+
Phlex::Reactive.freeze_param_types!
|
|
87
105
|
end
|
|
88
106
|
end
|
|
89
107
|
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# An immutable, chainable builder of client-side DOM commands (issue #95) —
|
|
6
|
+
# the ops behind Component#on_client. Each verb returns a NEW frozen
|
|
7
|
+
# instance; #to_json emits the wire format the generic controller's runOps
|
|
8
|
+
# action interprets, entirely in the browser:
|
|
9
|
+
#
|
|
10
|
+
# button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
|
|
11
|
+
# # -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'
|
|
12
|
+
#
|
|
13
|
+
# These are declarative DOM OPERATIONS, not state: nothing is shipped back
|
|
14
|
+
# to the server, nothing is trusted from the client, and any server
|
|
15
|
+
# re-render of the component resets whatever they toggled (the LiveView
|
|
16
|
+
# JS-commands caveat — by design; use a signed action for state that must
|
|
17
|
+
# survive re-renders).
|
|
18
|
+
#
|
|
19
|
+
# Targets: a CSS selector string is resolved WITHIN the component's root by
|
|
20
|
+
# default (nested reactive roots excluded, issue #15 semantics); `:root`
|
|
21
|
+
# targets the root element itself; `global: true` opts a single op out of
|
|
22
|
+
# root scoping (document escape hatch, e.g. a page-level overlay).
|
|
23
|
+
#
|
|
24
|
+
# The op vocabulary is a fixed whitelist mirrored by the client interpreter
|
|
25
|
+
# — an op name the client doesn't know is warn-and-skipped there
|
|
26
|
+
# (client-side default-deny). Validation here is deliberately LOUD: a bad
|
|
27
|
+
# target or an empty class list raises at render time rather than silently
|
|
28
|
+
# doing nothing in the browser.
|
|
29
|
+
class JS
|
|
30
|
+
# Serialized stand-in for "the component's own root element".
|
|
31
|
+
ROOT_SENTINEL = "@root"
|
|
32
|
+
|
|
33
|
+
# The attribute-name allowlist (issue #96) — the security-critical part of
|
|
34
|
+
# the attr ops, enforced HERE at build time AND again in the client
|
|
35
|
+
# interpreter (two-sided default-deny: a hand-built ops attr must not
|
|
36
|
+
# bypass it either). Rejected:
|
|
37
|
+
# * /\Aon/i — event-handler attributes (onclick, onmouseover) → XSS.
|
|
38
|
+
# * the URL set — href/src/srcdoc/action/formaction/xlink:href can carry
|
|
39
|
+
# a `javascript:` payload; setting them from client ops
|
|
40
|
+
# is a navigation/injection surface, not a UI toggle.
|
|
41
|
+
# * style — inline CSS injection; use classes (add_class/...).
|
|
42
|
+
# The INTENDED surface is class ops plus boolean/state attributes:
|
|
43
|
+
# hidden, disabled, open, selected, aria-*, data-*.
|
|
44
|
+
URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
|
|
45
|
+
EVENT_HANDLER_ATTR = /\Aon/i
|
|
46
|
+
|
|
47
|
+
# The attr ops whose args carry a "name" the allowlist must gate. Used to
|
|
48
|
+
# re-validate a RAW [op, args] list (the js([...]) / broadcast_js_to([...])
|
|
49
|
+
# escape hatch) that skips the builder's build-time attr_args check.
|
|
50
|
+
ATTR_NAME_OPS = %w[set_attr remove_attr toggle_attr].freeze
|
|
51
|
+
|
|
52
|
+
# Validate a raw ops list ([[op, args], ...] as passed to js/broadcast_js_to
|
|
53
|
+
# without the builder) against the attribute allowlist, so the escape hatch
|
|
54
|
+
# gets the SAME server-side default-deny as the JS chain (defense in depth;
|
|
55
|
+
# the client also enforces it). Non-attr ops and malformed entries pass
|
|
56
|
+
# through untouched — the client interpreter default-denies unknown ops.
|
|
57
|
+
def self.assert_ops_allowed!(list)
|
|
58
|
+
Array(list).each do |op, args|
|
|
59
|
+
next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)
|
|
60
|
+
|
|
61
|
+
name = args["name"] || args[:name]
|
|
62
|
+
assert_allowed_attr(name.to_s) if name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The attribute-name allowlist (issue #96), case-insensitive. Refuses
|
|
67
|
+
# event-handler (on*), URL-bearing, and style attributes. Enforced at build
|
|
68
|
+
# time by the instance builder AND on the raw-list escape hatch — two-sided
|
|
69
|
+
# default-deny with the client interpreter.
|
|
70
|
+
def self.assert_allowed_attr(name)
|
|
71
|
+
lower = name.downcase
|
|
72
|
+
if name.match?(EVENT_HANDLER_ATTR)
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"#{self}: attribute #{name.inspect} is an event handler (on*) — refused (XSS). " \
|
|
75
|
+
"Client attr ops target hidden/disabled/open/selected/aria-*/data-* and classes."
|
|
76
|
+
end
|
|
77
|
+
return unless URL_BEARING_ATTRS.include?(lower) || lower == "style"
|
|
78
|
+
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"#{self}: attribute #{name.inspect} is refused — URL-bearing attributes " \
|
|
81
|
+
"(#{URL_BEARING_ATTRS.join(", ")}) and `style` can't be set from client ops " \
|
|
82
|
+
"(injection surface). Use classes for styling; target aria-*/data-*/boolean attrs."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The accumulated [name, args] op pairs, oldest first. Frozen.
|
|
86
|
+
attr_reader :ops
|
|
87
|
+
|
|
88
|
+
def initialize(ops = [].freeze)
|
|
89
|
+
@ops = ops
|
|
90
|
+
freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Visibility (the `hidden` attribute) ---
|
|
94
|
+
#
|
|
95
|
+
# `transition:` (issue #96) — an optional [during, from, to] class triple
|
|
96
|
+
# animated around the visibility flip: `during`+`from` are applied, then on
|
|
97
|
+
# the next frame `from`→`to` swaps, and the whole set is awaited via
|
|
98
|
+
# `animationend` (with a setTimeout fallback so a non-animated element never
|
|
99
|
+
# hangs the op chain). Omit it for the instant flip.
|
|
100
|
+
|
|
101
|
+
def show(to, global: false, transition: nil)
|
|
102
|
+
append("show", target_args(to, global:, transition:))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hide(to, global: false, transition: nil)
|
|
106
|
+
append("hide", target_args(to, global:, transition:))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def toggle(to, global: false, transition: nil)
|
|
110
|
+
append("toggle", target_args(to, global:, transition:))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- Classes ---
|
|
114
|
+
|
|
115
|
+
def add_class(to, *classes, global: false)
|
|
116
|
+
append("add_class", class_args(to, classes, global:))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def remove_class(to, *classes, global: false)
|
|
120
|
+
append("remove_class", class_args(to, classes, global:))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def toggle_class(to, *classes, global: false)
|
|
124
|
+
append("toggle_class", class_args(to, classes, global:))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- Attributes (issue #96) — allowlisted names only ---
|
|
128
|
+
#
|
|
129
|
+
# set_attr(to, name, value) / remove_attr(to, name) / toggle_attr(to, name).
|
|
130
|
+
# The value is stringified (a Phlex-style flag rides as the string "true",
|
|
131
|
+
# never a valueless attribute). The name is checked against the allowlist at
|
|
132
|
+
# build time — an event-handler, URL-bearing, or style name raises here.
|
|
133
|
+
|
|
134
|
+
def set_attr(to, name, value, global: false)
|
|
135
|
+
append("set_attr", attr_args(to, name, global:, value:))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_attr(to, name, global: false)
|
|
139
|
+
append("remove_attr", attr_args(to, name, global:))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def toggle_attr(to, name, global: false)
|
|
143
|
+
append("toggle_attr", attr_args(to, name, global:))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# --- Focus (issue #96) ---
|
|
147
|
+
#
|
|
148
|
+
# focus(to) — focus the FIRST match of the selector.
|
|
149
|
+
# focus_first(to) — focus the first FOCUSABLE DESCENDANT of the match
|
|
150
|
+
# (e.g. focus the first menuitem inside an opened menu).
|
|
151
|
+
|
|
152
|
+
def focus(to, global: false)
|
|
153
|
+
append("focus", target_args(to, global:))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def focus_first(to, global: false)
|
|
157
|
+
append("focus_first", target_args(to, global:))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- Dispatch a bubbling CustomEvent (issue #96) ---
|
|
161
|
+
#
|
|
162
|
+
# dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
|
|
163
|
+
# components/controllers can react to a client-only interaction without a
|
|
164
|
+
# round trip. `to:` picks the element to dispatch ON (nil → the component
|
|
165
|
+
# root, serialized as the @root sentinel so the client resolves it
|
|
166
|
+
# uniformly); `detail:` is the event's `detail` payload. The client uses raw
|
|
167
|
+
# element.dispatchEvent — the shared controller SHADOWS Stimulus's
|
|
168
|
+
# this.dispatch helper, so the interpreter must not use it.
|
|
169
|
+
def dispatch(name, to: nil, detail: {}, global: false)
|
|
170
|
+
args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
|
|
171
|
+
args["global"] = true if global
|
|
172
|
+
append("dispatch", args.freeze)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The wire format: a JSON array of [op, args] pairs, applied in order.
|
|
176
|
+
def to_json(*)
|
|
177
|
+
@ops.to_json
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def empty?
|
|
181
|
+
@ops.empty?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# Immutability: every verb funnels here and returns a NEW frozen chain —
|
|
187
|
+
# a builder held in a constant or memo can never be mutated by later use.
|
|
188
|
+
def append(name, args)
|
|
189
|
+
self.class.new([*@ops, [name, args].freeze].freeze)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def target_args(to, global:, transition: nil)
|
|
193
|
+
args = { "to" => normalize_target(to) }
|
|
194
|
+
args["global"] = true if global
|
|
195
|
+
args["transition"] = normalize_transition(transition) if transition
|
|
196
|
+
args.freeze
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# An attr op's args: the target, the allowlisted name, an optional
|
|
200
|
+
# (stringified) value. The name is validated LOUDLY at build time.
|
|
201
|
+
def attr_args(to, name, global:, value: :__none)
|
|
202
|
+
name = name.to_s
|
|
203
|
+
assert_allowed_attr(name)
|
|
204
|
+
args = { "to" => normalize_target(to), "name" => name }
|
|
205
|
+
args["value"] = value.to_s unless value == :__none
|
|
206
|
+
args["global"] = true if global
|
|
207
|
+
args.freeze
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Build-time attr-name allowlist for the instance builder — delegates to the
|
|
211
|
+
# shared class-method check (also used by the raw-list escape hatch).
|
|
212
|
+
def assert_allowed_attr(name)
|
|
213
|
+
self.class.assert_allowed_attr(name)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A transition must be exactly [during, from, to] class lists (strings).
|
|
217
|
+
def normalize_transition(transition)
|
|
218
|
+
list = Array(transition)
|
|
219
|
+
unless list.size == 3
|
|
220
|
+
raise ArgumentError,
|
|
221
|
+
"#{self.class}: transition: must be [during, from, to] class lists, got #{transition.inspect}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
list.map(&:to_s).freeze
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def class_args(to, classes, global:)
|
|
228
|
+
if classes.empty?
|
|
229
|
+
raise ArgumentError, "#{self.class}: a class op needs at least one class (got none for #{to.inspect})"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
args = { "to" => normalize_target(to), "classes" => classes.map(&:to_s).freeze }
|
|
233
|
+
args["global"] = true if global
|
|
234
|
+
args.freeze
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# :root -> the sentinel; a String passes through as a CSS selector.
|
|
238
|
+
# Anything else (a stray symbol, nil) is a bug at the call site — raise at
|
|
239
|
+
# render time instead of silently matching nothing in the browser.
|
|
240
|
+
def normalize_target(to)
|
|
241
|
+
return ROOT_SENTINEL if to == :root
|
|
242
|
+
return to if to.is_a?(String)
|
|
243
|
+
|
|
244
|
+
raise ArgumentError,
|
|
245
|
+
"#{self.class}: target must be :root or a CSS selector string, got #{to.inspect}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|