phlex-reactive 0.13.4 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1079 -630
  3. data/README.md +612 -17
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3706 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +188 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +47 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +27 -7
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module Phlex
8
+ module Reactive
9
+ # Privately cacheable lazy renders (issue #277): the signed id and URL of a
10
+ # `reactive_lazy cache: { max_age: }` component's GET fragment.
11
+ #
12
+ # The id is a DETERMINISTIC signature over the component's identity payload
13
+ # — no expiry, no actor binding, purpose-scoped — so the same component
14
+ # renders the same URL on every page view and the browser's private HTTP
15
+ # cache can reuse the response. It names NO viewer: who the render is for
16
+ # always comes from the session at the endpoint. (Like the identity token it
17
+ # does carry the signed state / record gid — signed, not encrypted.) The
18
+ # purpose makes it disjoint
19
+ # from the identity and defer tokens: a fragment id can't run an action or
20
+ # reach the defer endpoint, and neither of those tokens resolves here.
21
+ #
22
+ # Two unsigned query parameters only shape the browser's cache KEY — the
23
+ # render never reads them:
24
+ # v — a digest of the component's reactive_cache_version (content changed)
25
+ # u — a KEYED digest of its reactive_cache_viewer (who the render is
26
+ # for). The endpoint recomputes it in the CURRENT session and makes
27
+ # the reply cacheable only when it matches, so a URL can only ever
28
+ # hold the render of the viewer it names. A blank viewer names nobody:
29
+ # no `u`, and the reply falls back to `Vary: Cookie`.
30
+ module Fragment
31
+ # The purpose of the previous id format (still verified, see verify).
32
+ PURPOSE = "phlex-reactive/fragment"
33
+ # The purpose the current id's MAC is keyed under (issue #306).
34
+ ID_PURPOSE = "phlex-reactive/fragment-id"
35
+ # 128 bits of MAC: 22 base64url characters.
36
+ MAC_BYTES = 16
37
+ MAC_LENGTH = 22
38
+ BASE64URL = /\A[A-Za-z0-9_-]+\z/
39
+ VIEWER_PURPOSE = "phlex-reactive/fragment-viewer"
40
+ # A viewer is an identity — an id, or a few of them. These bound how far
41
+ # a collection is walked, so no value can make the shell render slow.
42
+ VIEWER_PART_LIMIT = 32
43
+ VIEWER_NODE_LIMIT = 64
44
+ # Components already warned about (by class name): once per component.
45
+ VIEWER_WARNED = Concurrent::Map.new
46
+
47
+ # A reactive_cache_viewer value that cannot name a viewer. Internal: it
48
+ # carries the reason from the walk to viewer_param, which logs it.
49
+ class UnusableViewer < StandardError; end
50
+
51
+ module_function
52
+
53
+ # The signed id of an identity payload (issue #306):
54
+ #
55
+ # base64url(JSON payload) + base64url(128-bit MAC)
56
+ #
57
+ # The payload is encoded ONCE and the MAC is a fixed 22 characters, so the
58
+ # id is one URL path segment of base64url characters with no separator
59
+ # (a "." would read as a format suffix to the router). The MAC is keyed
60
+ # through the configured verifier — a digest of its signature over the
61
+ # encoded payload under its own purpose, the same construction as the
62
+ # viewer digest — so it works with any verifier the app configured and
63
+ # cannot be computed without the app's secret. Deterministic: the same
64
+ # identity always renders the same URL, which is what lets the browser's
65
+ # cache hit.
66
+ def sign(payload)
67
+ data = Base64.urlsafe_encode64(JSON.generate(payload.merge("v" => TOKEN_VERSION)), padding: false)
68
+ "#{data}#{compact_mac(data)}"
69
+ end
70
+
71
+ # The verified, version-upgraded payload, or nil — tampered, a token of
72
+ # another purpose, or no fragment id at all. The current format is tried
73
+ # first, then the previous one: a browser may still hold a page or a
74
+ # cached fragment URL minted before #306 (for up to
75
+ # fragment_cache_max_age_limit).
76
+ def verify(id)
77
+ id = id.to_s
78
+ payload = verify_compact(id) || verify_legacy(id)
79
+ payload && Phlex::Reactive.upgrade_token(payload)
80
+ end
81
+
82
+ # The MAC is checked BEFORE the data is decoded or parsed. Anything but
83
+ # base64url characters is not an id (and never reaches the verifier).
84
+ def verify_compact(id)
85
+ return nil unless id.length > MAC_LENGTH && id.match?(BASE64URL)
86
+
87
+ data = id[0...-MAC_LENGTH]
88
+ return nil unless ActiveSupport::SecurityUtils.secure_compare(compact_mac(data), id[-MAC_LENGTH..])
89
+
90
+ payload = JSON.parse(Base64.urlsafe_decode64(data))
91
+ payload.is_a?(::Hash) ? payload : nil
92
+ rescue ::ArgumentError, JSON::ParserError
93
+ nil
94
+ end
95
+
96
+ # The previous format: the verifier's token (Base64 + "--" + digest)
97
+ # wrapped in urlsafe Base64. Kept for one release (issue #306).
98
+ def verify_legacy(id)
99
+ Phlex::Reactive.verifier.verified(Base64.urlsafe_decode64(id), purpose: PURPOSE)
100
+ rescue ::ArgumentError
101
+ nil
102
+ end
103
+
104
+ # `data` is pinned to US-ASCII (it is base64url): the id is minted as a
105
+ # US-ASCII String but arrives as a UTF-8 param, and a Marshal-serializing
106
+ # verifier (load_defaults < 7.1) signs the encoding along with the bytes.
107
+ def compact_mac(data)
108
+ data = data.dup.force_encoding(Encoding::US_ASCII)
109
+ digest_param(Digest::SHA256.digest(Phlex::Reactive.verifier.generate(data, purpose: ID_PURPOSE)), MAC_BYTES)
110
+ end
111
+
112
+ # The fragment URL the shell renders into data-reactive-defer-src:
113
+ # <fragment_path>/<signed id>[?v=<version digest>][&u=<viewer digest>].
114
+ # `version` is the component's reactive_cache_version (nil → no `v`);
115
+ # `viewer` is its ALREADY-DIGESTED viewer_param (nil → no `u`).
116
+ def src(payload, version: nil, viewer: nil)
117
+ query = []
118
+ query << "v=#{version_param(version)}" unless version.nil?
119
+ query << "u=#{viewer}" if viewer
120
+ path = "#{Phlex::Reactive.fragment_path}/#{sign(payload)}"
121
+ query.empty? ? path : "#{path}?#{query.join("&")}"
122
+ end
123
+
124
+ # `v` only varies the browser's cache KEY — the endpoint never reads it —
125
+ # so it is digested: short, opaque, and safe for any value an app returns
126
+ # (a Time keeps its sub-second precision, a record its cache key). 64
127
+ # bits as 11 base64url characters: a cache-buster, not a secret.
128
+ def version_param(version)
129
+ key = version.respond_to?(:utc) ? version.utc.strftime("%Y%m%d%H%M%S%N") : version
130
+ digest_param(Digest::SHA256.digest(ActiveSupport::Cache.expand_cache_key(key)), 8)
131
+ end
132
+
133
+ def digest_param(digest, bytes)
134
+ Base64.urlsafe_encode64(digest.byteslice(0, bytes), padding: false)
135
+ end
136
+
137
+ # The `u` of a reactive_cache_viewer value, or nil when the value names
138
+ # NO viewer — nil, false, a blank String, or an Array with any blank part
139
+ # or Hash with any blank leaf (`[Current.user&.id, locale]` signed out),
140
+ # or a value whose cache key is empty. A blank viewer must never
141
+ # become a shared "anonymous" key: every signed-out or mis-resolved
142
+ # session would then store and read ONE url with no Vary. nil sends the
143
+ # component back to the default mode (`Vary: Cookie`) for that render.
144
+ #
145
+ # KEYED: the digest is taken over the verifier's signature of the value,
146
+ # so it can be neither reversed to the value (a user id is a small space)
147
+ # nor computed for someone else without the app's secret. 128 bits, as
148
+ # 22 base64url characters.
149
+ #
150
+ # NEVER RAISES and never takes long, whatever the hook returned: this runs
151
+ # inside the host page's render. A value that cannot be turned into a key
152
+ # (it raises, it is endless, it is huge) names nobody — the fail-closed
153
+ # default mode — and is logged once per component (`owner`, its class).
154
+ def viewer_param(viewer, owner: nil)
155
+ return nil unless viewer_named?(viewer)
156
+
157
+ signed = Phlex::Reactive.verifier.generate(ActiveSupport::Cache.expand_cache_key(viewer),
158
+ purpose: VIEWER_PURPOSE)
159
+ digest_param(Digest::SHA256.digest(signed), 16)
160
+ rescue UnusableViewer => e
161
+ warn_unusable_viewer(owner, e.message)
162
+ rescue StandardError => e
163
+ warn_unusable_viewer(owner, "#{e.class}: #{e.message}")
164
+ end
165
+
166
+ def viewer_named?(viewer)
167
+ named_within?(viewer, [VIEWER_NODE_LIMIT])
168
+ end
169
+
170
+ # Every LEAF must name something. A collection — anything
171
+ # expand_cache_key walks with to_a: an Array, a Hash (keys and values), a
172
+ # Set, a Struct, a sized Enumerator, nested — with one blank part is
173
+ # unnamed, and so is a value whose cache key expands to nothing (an
174
+ # object with a blank to_param). 0 is a viewer.
175
+ #
176
+ # Bounded: a Range is never walked, a collection of more than
177
+ # VIEWER_PART_LIMIT parts or a walk of more than VIEWER_NODE_LIMIT values
178
+ # (a self-referential Array, a to_a that returns itself) is unnamed.
179
+ # `budget` is a one-element counter shared by the whole walk.
180
+ def named_within?(value, budget)
181
+ return false if value.nil? || value == false
182
+ raise UnusableViewer, "a Range is not an identity" if value.is_a?(::Range)
183
+ raise UnusableViewer, "too deep or self-referential" if (budget[0] -= 1).negative?
184
+ return viewer_leaf?(value) unless value.respond_to?(:to_a) && !value.respond_to?(:cache_key)
185
+
186
+ size = value.respond_to?(:size) ? value.size : nil
187
+ raise UnusableViewer, "a collection of unknown or excessive size" unless viewer_size_ok?(size, value)
188
+
189
+ parts = value.to_a
190
+ raise UnusableViewer, "more than #{VIEWER_PART_LIMIT} parts" if parts.size > VIEWER_PART_LIMIT
191
+
192
+ parts.any? && parts.all? { named_within?(it, budget) }
193
+ end
194
+
195
+ def viewer_leaf?(value)
196
+ value.present? && ActiveSupport::Cache.expand_cache_key(value).present?
197
+ end
198
+
199
+ # An Enumerator must report a finite size; anything sized must be small.
200
+ def viewer_size_ok?(size, value)
201
+ return size <= VIEWER_PART_LIMIT if size.is_a?(::Integer)
202
+
203
+ !value.is_a?(::Enumerator) && size.nil?
204
+ end
205
+
206
+ # Logs why — once per COMPONENT, naming it, since the cause is that
207
+ # component's hook — and answers nil: "this value names nobody".
208
+ def warn_unusable_viewer(owner, reason)
209
+ name = owner.respond_to?(:name) ? owner.name.to_s : owner.to_s
210
+ return nil if VIEWER_WARNED.put_if_absent(name, true)
211
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
212
+
213
+ ::Rails.logger&.warn(
214
+ "[phlex-reactive] #{name.presence || "a component"}#reactive_cache_viewer returned a value that " \
215
+ "cannot name a viewer (#{reason}) — treated as no viewer (Vary: Cookie). Return an id, or a small " \
216
+ "Array of ids."
217
+ )
218
+ nil
219
+ end
220
+
221
+ # Test seam: let the once-per-component warnings fire again.
222
+ def reset_viewer_warning!
223
+ VIEWER_WARNED.clear
224
+ end
225
+
226
+ # The max-age (seconds) the endpoint answers with: the component's
227
+ # declared value, capped by Phlex::Reactive.fragment_cache_max_age_limit.
228
+ def max_age_for(component_class)
229
+ [component_class.reactive_lazy_cache.fetch(:max_age), Phlex::Reactive.fragment_cache_max_age_limit.to_i].min
230
+ end
231
+ end
232
+ end
233
+ end
@@ -15,7 +15,13 @@ module Phlex
15
15
  # A plain-text table of every component × action, or a JSON array when
16
16
  # `format` is :json. One row per action: component, action, params,
17
17
  # file:line, authorization heuristic.
18
- def actions(components, format: :text)
18
+ # `unverified_only:` keeps just the actions whose authorization_state is
19
+ # :none (the review queue) and drops components left with none.
20
+ def actions(components, format: :text, unverified_only: false)
21
+ if unverified_only
22
+ components = unverified(components)
23
+ return "no unverified actions" if components.empty? && format != :json
24
+ end
19
25
  return actions_json(components) if format == :json
20
26
 
21
27
  actions_text(components)
@@ -34,6 +40,13 @@ module Phlex
34
40
  lines.join("\n")
35
41
  end
36
42
 
43
+ def unverified(components)
44
+ components.filter_map do
45
+ actions = it.actions.select { it.authorization_state == :none }
46
+ it.with(actions:) if actions.any?
47
+ end
48
+ end
49
+
37
50
  # -- plain text -------------------------------------------------------
38
51
 
39
52
  def actions_text(components)
@@ -110,7 +123,9 @@ module Phlex
110
123
  name: action.name,
111
124
  params: action.params,
112
125
  source_location: location_str(action.source_location),
113
- authorization_call_detected: action.authorization_call_detected?
126
+ authorization_call_detected: action.authorization_call_detected?,
127
+ authorization: action.authorization_state.to_s,
128
+ authorization_skip: action.authorization_skip&.to_s
114
129
  }
115
130
  end
116
131
 
@@ -131,8 +146,14 @@ module Phlex
131
146
  end
132
147
 
133
148
  # The authorization heuristic as a short label — advisory only.
149
+ # "authorized*" (call detected), "skipped" / "skipped (class)" (declared
150
+ # skip_verify_authorized), "unverified" (neither).
134
151
  def auth_str(action)
135
- action.authorization_call_detected? ? "authorized*" : "unverified"
152
+ case action.authorization_state
153
+ when :detected then "authorized*"
154
+ when :skipped then action.authorization_skip == :class ? "skipped (class)" : "skipped"
155
+ else "unverified"
156
+ end
136
157
  end
137
158
  end
138
159
  end
@@ -31,7 +31,10 @@ module Phlex
31
31
  # of the definition finds a call to any configured
32
32
  # authorization method or mark_authorized!. A helper
33
33
  # may authorize indirectly, so this is advisory ONLY.
34
- ActionInfo = Data.define(:name, :params, :source_location, :definition) do
34
+ # * authorization_skip — :class (a bare skip_verify_authorized, own or
35
+ # inherited), :action (this action is named in one),
36
+ # or nil. The author's declared opt-out (issue #278).
37
+ ActionInfo = Data.define(:name, :params, :source_location, :definition, :authorization_skip) do
35
38
  # Was any authorization method (or mark_authorized!) called directly in
36
39
  # the method body? Heuristic — a Prism scan of `definition`. False when
37
40
  # the definition is unavailable.
@@ -40,6 +43,14 @@ module Phlex
40
43
 
41
44
  Inspector.authorization_call?(definition)
42
45
  end
46
+
47
+ # :detected (a call was found), :skipped (declared skip_verify_authorized)
48
+ # or :none (neither — the review queue). A detected call wins over a skip.
49
+ def authorization_state
50
+ return :detected if authorization_call_detected?
51
+
52
+ authorization_skip ? :skipped : :none
53
+ end
43
54
  end
44
55
 
45
56
  # One reactive component's introspection.
@@ -164,10 +175,20 @@ module Phlex
164
175
  name: action.name,
165
176
  params: action.params,
166
177
  source_location: location,
167
- definition: (extract_definition(location) if location)
178
+ definition: (extract_definition(location) if location),
179
+ authorization_skip: authorization_skip(klass, action.name)
168
180
  )
169
181
  end
170
182
 
183
+ # How `name` opts out of verify_authorized: :class for a bare skip (own
184
+ # or inherited), :action when listed by name, else nil. Resolved through
185
+ # the same Registry readers the runtime guard uses.
186
+ def authorization_skip(klass, name)
187
+ return :class if klass.reactive_skip_all_authorization?
188
+
189
+ :action if klass.reactive_skip_authorization_actions.include?(name.to_sym)
190
+ end
191
+
171
192
  # The method's [file, line], or nil when the declared method is missing
172
193
  # (an undeclared/typo'd action — the endpoint would 500 on public_send).
173
194
  def method_location(klass, name)
@@ -196,9 +196,24 @@ module Phlex
196
196
  # "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
197
197
  # reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
198
198
  # submit would force-submit every subscriber's form.
199
+ #
200
+ # submitter: (issue #319) — a CSS selector for the submit control to
201
+ # submit THROUGH (form.requestSubmit(submitter)), so the request carries
202
+ # its name=value — the bulk-action "Delete" that posts action=delete. It
203
+ # resolves with the op's own scoping; one that isn't a submit control of
204
+ # that form warns in the browser and falls back to a plain submit.
205
+ # js.submit("#bulk", submitter: "#delete-submit")
206
+
207
+ def submit(to = :root, global: false, submitter: nil)
208
+ args = target_args(to, global:)
209
+ return append("submit", args) if submitter.nil?
210
+
211
+ unless submitter.is_a?(String) && !submitter.empty?
212
+ raise ArgumentError,
213
+ "js.submit submitter: must be a CSS selector string naming a submit control, got #{submitter.inspect}"
214
+ end
199
215
 
200
- def submit(to = :root, global: false)
201
- append("submit", target_args(to, global:))
216
+ append("submit", args.merge("submitter" => submitter).freeze)
202
217
  end
203
218
 
204
219
  # --- Clipboard-source paste (issue #228) ---
@@ -244,6 +259,36 @@ module Phlex
244
259
  append("text", args.freeze)
245
260
  end
246
261
 
262
+ # --- Tick or untick a checkbox group (issue #342) ---
263
+ #
264
+ # check_group(group, checked = true) — set every OWNED box of the group
265
+ # (the #15 rule: a nested root's boxes are its own) the way a
266
+ # reactive_select_all header does: every box first, then `input` +
267
+ # `change` on each one it flipped, then ONE re-sync of the header, count,
268
+ # show and enable bindings. The group is a checkbox name resolved like
269
+ # reactive_select_all's: a bare name takes reactive_scope, a bracketed one
270
+ # ("ids[]") is used verbatim. `global: true` is for a trigger outside the
271
+ # root that owns the group: EVERY connected reactive root on the page flips the
272
+ # boxes of that name it owns (nested roots included). ACTOR-ONLY like focus/submit: a broadcast that cleared
273
+ # every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
274
+ # button(**on_client(:click, js.check_group("ids[]", false))) { "✕" }
275
+
276
+ # Positional `checked` on purpose: js.check_group("ids[]", false) reads as
277
+ # the op it is (the issue #342 API).
278
+ def check_group(group, checked = true, global: false) # rubocop:disable Style/OptionalBooleanParameter
279
+ name = -group.to_s
280
+ if name.strip.empty?
281
+ raise ArgumentError, "check_group needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
282
+ end
283
+ unless [true, false].include?(checked)
284
+ raise ArgumentError, "check_group(#{name.inspect}) takes true or false, got #{checked.inspect}"
285
+ end
286
+
287
+ args = { "to" => ROOT_SENTINEL, "group" => name, "checked" => checked }
288
+ args["global"] = true if global
289
+ append("check_group", args.freeze)
290
+ end
291
+
247
292
  # --- Dispatch a bubbling CustomEvent (issue #96) ---
248
293
  #
249
294
  # dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
@@ -15,7 +15,9 @@ module Phlex
15
15
  action with its param schema, source file:line, and a heuristic
16
16
  authorization status (whether an authorization method or
17
17
  mark_authorized! call was detected in the body — advisory only, since a
18
- helper may authorize indirectly). Pass `component:` to scope to one
18
+ helper may authorize indirectly), plus `authorization` — detected,
19
+ skipped (declared skip_verify_authorized; `authorization_skip` says
20
+ class or action) or none. Pass `component:` to scope to one
19
21
  component (exact name). Read-only — schemas and paths, never runtime
20
22
  values.
21
23
  DESC
@@ -15,7 +15,8 @@ module Phlex
15
15
  Fuzzy-find reactive components by name (exact > prefix > substring >
16
16
  subsequence, on both the demodulized and full name). Returns the ranked
17
17
  matches, each with its actions' param schema, source location,
18
- authorization heuristic, and the full method-definition SOURCE
18
+ authorization (detected / skipped / none, plus the skip form), and the
19
+ full method-definition SOURCE
19
20
  (extracted with Prism). Read-only.
20
21
 
21
22
  NOTE: unlike the other tools (which report names/paths/schemas only),
@@ -53,6 +54,8 @@ module Phlex
53
54
  params: action.params,
54
55
  source_location: Phlex::Reactive::Inspector::Report.location_str(action.source_location),
55
56
  authorization_call_detected: action.authorization_call_detected?,
57
+ authorization: action.authorization_state.to_s,
58
+ authorization_skip: action.authorization_skip&.to_s,
56
59
  # The full method SOURCE — this is the one field that exposes an
57
60
  # action body verbatim (see the tool description). A secret hardcoded
58
61
  # in an action would surface here; keep secrets out of action bodies.
@@ -334,36 +334,10 @@ module Phlex
334
334
  value.is_a?(Hash) || value.respond_to?(:to_unsafe_h)
335
335
  end
336
336
 
337
- # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
338
- # Hash so coercion can index it uniformly, then expand bracket notation so
339
- # a model-scoped Rails form's flat keys nest (issue #21).
340
- def to_param_hash(value)
341
- flat =
342
- if value.respond_to?(:to_unsafe_h)
343
- value.to_unsafe_h.stringify_keys
344
- elsif value.is_a?(Hash)
345
- value.stringify_keys
346
- else
347
- return {}
348
- end
349
-
350
- expand_bracket_keys(flat)
351
- end
352
-
353
- # The client's #collectFields keeps a form input's name verbatim, so a
354
- # Rails Form(model: @invoice) posts FLAT bracketed keys like
355
- # "invoice[date]". Coercion does exact-key matching, so without this a
356
- # nested schema (params: { invoice: { date: … } }) never finds "invoice"
357
- # and drops everything. Expand "invoice[date]" => "2026-01-02" into
358
- # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
359
- # the Rails index-hash form coerce_array already understands — deep-merging
360
- # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
361
- # brackets and already-nested values pass through untouched.
362
- def expand_bracket_keys(flat)
363
- flat.each_with_object({}) do |(key, value), out|
364
- deep_assign(out, bracket_path(key), value)
365
- end
366
- end
337
+ # Each nested hash is unwrapped and expanded by the class-level expand
338
+ # below — the same expansion the endpoint runs before peeling a
339
+ # reactive_scope (issue #337), so both read one shape.
340
+ def to_param_hash(value) = self.class.expand(value)
367
341
 
368
342
  # Matches each bracket segment in "items_attributes][0][qty]" — the part
369
343
  # after the first "[". Hoisted to a frozen constant so coercing a bracketed
@@ -371,35 +345,75 @@ module Phlex
371
345
  BRACKET_SEGMENT = /[^\[\]]+/
372
346
  private_constant :BRACKET_SEGMENT
373
347
 
374
- # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
375
- # "0", "qty"]. A key with no brackets is a single-element path.
376
- def bracket_path(key)
377
- return [key] unless key.include?("[")
348
+ class << self
349
+ # Public so the endpoint can expand BEFORE it peels a reactive_scope
350
+ # (issue #337): a JSON body keeps "todo[title]" as one literal key, which
351
+ # only becomes { "todo" => { "title" => … } } here. Idempotent on input
352
+ # that is already nested (a multipart body Rails expanded itself).
353
+ #
354
+ # Unwraps ActionController::Parameters (or a plain Hash) to a string-keyed
355
+ # Hash so coercion can index it uniformly, then expands bracket notation so
356
+ # a model-scoped Rails form's flat keys nest (issue #21). Anything else is {}.
357
+ def expand(value)
358
+ flat =
359
+ if value.respond_to?(:to_unsafe_h)
360
+ value.to_unsafe_h.stringify_keys
361
+ elsif value.is_a?(Hash)
362
+ value.stringify_keys
363
+ else
364
+ return {}
365
+ end
378
366
 
379
- head, rest = key.split("[", 2)
380
- [head, *rest.scan(BRACKET_SEGMENT)]
381
- end
367
+ expand_bracket_keys(flat)
368
+ end
382
369
 
383
- # Walk/create nested hashes along `path`, then merge `value` at the leaf so
384
- # a bracket key and a sibling pre-nested object coalesce regardless of which
385
- # arrives first. #merge_value deep-merges hash/hash collisions and otherwise
386
- # lets the later value win.
387
- def deep_assign(hash, path, value)
388
- *parents, leaf = path
389
- node = parents.reduce(hash) do |acc, segment|
390
- acc[segment] = {} unless acc[segment].is_a?(Hash)
391
- acc[segment]
370
+ private
371
+
372
+ # The client's #collectFields keeps a form input's name verbatim, so a
373
+ # Rails Form(model: @invoice) posts FLAT bracketed keys like
374
+ # "invoice[date]". Coercion does exact-key matching, so without this a
375
+ # nested schema (params: { invoice: { date: … } }) never finds "invoice"
376
+ # and drops everything. Expand "invoice[date]" => "2026-01-02" into
377
+ # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
378
+ # the Rails index-hash form coerce_array already understands — deep-merging
379
+ # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
380
+ # brackets and already-nested values pass through untouched.
381
+ def expand_bracket_keys(flat)
382
+ flat.each_with_object({}) do |(key, value), out|
383
+ deep_assign(out, bracket_path(key), value)
384
+ end
392
385
  end
393
- node[leaf] = merge_value(node[leaf], value)
394
- end
395
386
 
396
- # Combine an existing leaf value with a new one. Two hashes deep-merge (so
397
- # bracket-expanded fields and a pre-nested object for the same key both
398
- # survive); any other collision takes the new value.
399
- def merge_value(existing, value)
400
- return value unless existing.is_a?(Hash) && value.is_a?(Hash)
387
+ # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
388
+ # "0", "qty"]. A key with no brackets is a single-element path.
389
+ def bracket_path(key)
390
+ return [key] unless key.include?("[")
391
+
392
+ head, rest = key.split("[", 2)
393
+ [head, *rest.scan(BRACKET_SEGMENT)]
394
+ end
401
395
 
402
- existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
396
+ # Walk/create nested hashes along `path`, then merge `value` at the leaf so
397
+ # a bracket key and a sibling pre-nested object coalesce regardless of which
398
+ # arrives first. #merge_value deep-merges hash/hash collisions and otherwise
399
+ # lets the later value win.
400
+ def deep_assign(hash, path, value)
401
+ *parents, leaf = path
402
+ node = parents.reduce(hash) do |acc, segment|
403
+ acc[segment] = {} unless acc[segment].is_a?(Hash)
404
+ acc[segment]
405
+ end
406
+ node[leaf] = merge_value(node[leaf], value)
407
+ end
408
+
409
+ # Combine an existing leaf value with a new one. Two hashes deep-merge (so
410
+ # bracket-expanded fields and a pre-nested object for the same key both
411
+ # survive); any other collision takes the new value.
412
+ def merge_value(existing, value)
413
+ return value unless existing.is_a?(Hash) && value.is_a?(Hash)
414
+
415
+ existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
416
+ end
403
417
  end
404
418
  end
405
419
  end