phlex-reactive 0.4.7 → 0.9.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +970 -0
  3. data/README.md +1134 -49
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +93 -0
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +37 -348
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +36 -7
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +20 -1
@@ -27,27 +27,112 @@ module Phlex
27
27
  wrap_parameters false if respond_to?(:wrap_parameters)
28
28
 
29
29
  def create
30
+ # ONE action.phlex_reactive event per request (issue #107). The event
31
+ # payload carries the component/action NAMES + outcome ONLY (never the
32
+ # token, params, or state); we fill it as those become known and set
33
+ # :outcome on every exit path — the success tail and each rescue. The
34
+ # rescue bodies are unchanged (verbose diagnostics + reactive_error per
35
+ # #82/#87); we only ADD the outcome finalizer. `action` is safe to read
36
+ # up front (it comes from the request, not the verified token).
37
+ event = { component: nil, action: reactive_action_name.to_s, outcome: nil }
38
+ Phlex::Reactive.instrument("action", event) do
39
+ create_action(event)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # The action body, run inside the instrument block so its payload (`event`)
46
+ # can be finalized on every exit path. Kept separate so `create` stays a
47
+ # thin instrument wrapper.
48
+ def create_action(event)
30
49
  payload = verified_payload
31
50
  component_class = resolve_component(payload["c"])
51
+ event[:component] = component_class.name
32
52
  action_def = component_class.reactive_action(reactive_action_name)
33
53
 
34
- return head(:forbidden) unless action_def # default-deny
54
+ # default-deny
55
+ unless action_def
56
+ event[:outcome] = :denied_undeclared
57
+ return reactive_error(:forbidden, undeclared_action_message(component_class), kind: :forbidden)
58
+ end
35
59
 
36
60
  component = component_class.from_identity(payload)
37
- coerced = coerce_params(action_def.params)
61
+ coerced = coerce_params(action_def, component_class:, action_name: action_def.name)
38
62
 
39
63
  result = run_action(component, action_def, coerced)
40
64
 
65
+ event[:outcome] = :ok
41
66
  render turbo_stream: response_streams(result, component)
42
- rescue Phlex::Reactive::InvalidToken
43
- head :bad_request
67
+ rescue Phlex::Reactive::InvalidToken => e
68
+ # The component name here came from an UNVERIFIED token — do NOT report it
69
+ # as trusted. Leave event[:component] nil.
70
+ event[:outcome] = :invalid_token
71
+ reactive_error(:bad_request, e.message, kind: e.diagnostic || :tampered)
44
72
  rescue ActiveRecord::RecordNotFound
45
- head :not_found
46
- rescue *authorization_errors
47
- head :forbidden
73
+ event[:outcome] = :not_found
74
+ reactive_error(:not_found, record_not_found_message(payload), kind: :not_found)
75
+ rescue *authorization_errors => e
76
+ event[:outcome] = :unauthorized
77
+ reactive_error(:forbidden, authorization_error_message(e, component_class, action_def), kind: :forbidden)
48
78
  end
49
79
 
50
- private
80
+ # Reply to an endpoint failure. The status NEVER changes with any flag —
81
+ # only the body. Precedence for the body (issue #100):
82
+ # 1. error_flash set → a turbo-stream flash the user actually SEES (the
83
+ # client renders non-OK turbo-stream bodies). It wins over the verbose
84
+ # diagnostic (both can't be the body); the diagnostic still logs below.
85
+ # 2. else verbose_errors → the plain-text diagnostic (client console.errors it).
86
+ # 3. else a bare head.
87
+ # The warn log fires in EVERY environment first, so a misbehaving client is
88
+ # debuggable from the server log alone regardless of which body path runs.
89
+ def reactive_error(status, message, kind: nil)
90
+ ::Rails.logger&.warn("[phlex-reactive] #{message}") if defined?(::Rails) && ::Rails.respond_to?(:logger)
91
+
92
+ flash = error_flash_stream(kind)
93
+ if flash
94
+ render turbo_stream: flash, status: status
95
+ elsif Phlex::Reactive.verbose_errors
96
+ render plain: message, status: status
97
+ else
98
+ head status
99
+ end
100
+ end
101
+
102
+ # Build the error flash turbo-stream when Phlex::Reactive.error_flash is
103
+ # configured, else nil (the non-flash body paths run). Degrades gracefully:
104
+ # a lambda that raises returns nil so the endpoint falls back to the bare/
105
+ # diagnostic body and NEVER turns one failure into a 500.
106
+ def error_flash_stream(kind)
107
+ callable = Phlex::Reactive.error_flash
108
+ return unless callable
109
+
110
+ message = callable.call(kind)
111
+ Phlex::Reactive::Response.flash_stream(:error, message, target: Phlex::Reactive.flash_target)
112
+ rescue => e # rubocop:disable Style/RescueStandardError
113
+ ::Rails.logger&.warn("[phlex-reactive] error_flash raised: #{e.message}") if defined?(::Rails) &&
114
+ ::Rails.respond_to?(:logger)
115
+ nil
116
+ end
117
+
118
+ def undeclared_action_message(component_class)
119
+ declared = component_class.reactive_actions.keys.join(", ")
120
+ "action :#{reactive_action_name} is not declared on #{component_class.name} — " \
121
+ "declared actions: #{declared}"
122
+ end
123
+
124
+ def record_not_found_message(payload)
125
+ gid = payload.is_a?(Hash) && payload["gid"]
126
+ return "record not found" unless gid
127
+
128
+ "record #{gid} not found — deleted while a page still showed it?"
129
+ end
130
+
131
+ def authorization_error_message(error, component_class, action_def)
132
+ where = [component_class&.name, action_def&.name].compact.join("#")
133
+ "#{error.class.name} raised in #{where} — the signature proves identity, not permission; " \
134
+ "authorize in the action"
135
+ end
51
136
 
52
137
  # Run the action inside a transaction so transactional broadcasts (pgbus
53
138
  # broadcasts_to ... durable:) defer to after_commit and never fire for a
@@ -57,18 +142,60 @@ module Phlex
57
142
  # for the duration of the action via Phlex::Reactive.current_connection_id,
58
143
  # so a broadcast in the action can pass exclude: reactive_connection_id
59
144
  # and skip the actor's own echo.
145
+ #
146
+ # The component-aware around_action stack (issue #112) folds in HERE —
147
+ # INSIDE with_connection_id (so a wrapper's own broadcast can exclude the
148
+ # actor) but OUTSIDE transaction_wrapper (so a rate-limit rejection never
149
+ # opens a transaction, and an audit wrapper observes commit/rollback). The
150
+ # fold returns the continuation's value unchanged, so the action's
151
+ # Phlex::Reactive::Response survives to response_streams.
60
152
  def run_action(component, action_def, coerced)
61
153
  Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
62
- transaction_wrapper do
63
- if coerced.any?
64
- component.public_send(action_def.name, **coerced)
65
- else
66
- component.public_send(action_def.name)
154
+ with_around_actions(component, action_def, coerced) do
155
+ transaction_wrapper do
156
+ if coerced.any?
157
+ component.public_send(action_def.name, **coerced)
158
+ else
159
+ component.public_send(action_def.name)
160
+ end
67
161
  end
68
162
  end
69
163
  end
70
164
  end
71
165
 
166
+ # Fold the registered around_action wrappers around `block`, innermost being
167
+ # the transactioned action. The empty-stack fast path is a bare `yield` —
168
+ # the default request gains only one Array#empty? check. Each wrapper is
169
+ # called with the frozen ActionContext and the continuation as its block, so
170
+ # a well-behaved wrapper returns action.call's value and the action's
171
+ # Response propagates out unchanged (issue #112).
172
+ #
173
+ # Fold: seed the accumulator with the innermost action, then walk the stack
174
+ # oldest → newest wrapping each wrapper AROUND the accumulated continuation.
175
+ # The FIRST-registered wrapper wraps the action, and each later wrapper wraps
176
+ # that — so the LAST-registered runs OUTERMOST (LIFO nesting).
177
+ def with_around_actions(component, action_def, coerced, &block)
178
+ stack = Phlex::Reactive.around_actions
179
+ return yield if stack.empty?
180
+
181
+ # A frozen DUP of the coerced params, not the live hash: the same hash is
182
+ # splatted into the action below, so sharing it would let a wrapper mutate
183
+ # ctx.params and alter the action's actual inputs — breaking both the
184
+ # observe-only context contract and the schema-coercion guarantee. The
185
+ # dup+freeze runs only when a wrapper is registered (never the empty-stack
186
+ # hot path).
187
+ ctx = Phlex::Reactive::ActionContext.new(
188
+ component: component,
189
+ action_name: action_def.name,
190
+ params: coerced.dup.freeze,
191
+ request: request
192
+ )
193
+
194
+ stack.reduce(block) do |inner, wrapper|
195
+ -> { wrapper.call(ctx, &inner) }
196
+ end.call
197
+ end
198
+
72
199
  # Turn the action's return value into the turbo-stream(s) to render for
73
200
  # the actor. A Phlex::Reactive::Response is honored explicitly; any other
74
201
  # value (the legacy contract — return value ignored) falls back to the
@@ -101,49 +228,89 @@ module Phlex
101
228
  # replace when a hand-built `with(...)` stream omits it. Idempotent: a
102
229
  # Response.replace(self)/update(self) already carries the token, so we
103
230
  # don't double the self-render.
104
- if result.render_self? && streams.none? { it.include?("data-reactive-token-value") }
231
+ #
232
+ # GUARD 2 (issue #114): GLOBAL, un-scoped — "does ANY stream carry a
233
+ # token?", NOT target-scoped. A Stream answers from its precomputed
234
+ # ground-truth flag (rx_carries_token?), a raw string from a substring
235
+ # scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
236
+ # this guard would regress update/morph of self on an aliased id.
237
+ if result.render_self? && streams.none? { stream_carries_token?(it) }
105
238
  streams = [component.to_stream_replace, *streams]
106
239
  end
107
240
  streams
108
241
  end
109
242
 
243
+ # GUARD 2 predicate. A Stream with intact metadata answers structurally
244
+ # (O(1) flag read); a raw string / metadata-degraded object falls back to
245
+ # the substring scan the endpoint always used.
246
+ def stream_carries_token?(stream)
247
+ if stream.is_a?(Phlex::Reactive::Stream) && stream.rx_action
248
+ stream.rx_carries_token?
249
+ else
250
+ stream.include?(Phlex::Reactive::Stream::TOKEN_ATTR)
251
+ end
252
+ end
253
+
110
254
  # Actions that RE-RENDER the component's own root (so the root's fresh
111
255
  # data-reactive-token-value rolls the signed token forward). `append`/
112
256
  # `prepend` are deliberately excluded: they insert CHILDREN into the
113
257
  # component, and a reactive child carries its OWN token — that child token is
114
258
  # not the component's (issue #44). reactive:token is our inert token-only
115
259
  # refresh; replace/update re-render the root.
260
+ #
261
+ # Issue #114: a Phlex::Reactive::Stream now knows its own action structurally
262
+ # (Stream::SELF_RENDER_ACTIONS), so this allowlist survives ONLY for the
263
+ # LEGACY regex fallback below — the path raw strings (reply.with,
264
+ # interpolated/degraded streams) take. The primary path is structural.
116
265
  SELF_RENDER_ACTIONS = %w[replace update reactive:token].freeze
117
266
  private_constant :SELF_RENDER_ACTIONS
118
267
 
119
- # True when one of `streams` already carries a fresh token by RE-RENDERING
120
- # this component itself — i.e. the caller hand-built the actor's own
121
- # token-bearing stream, so appending to_stream_token would double it.
268
+ # GUARD 1 (issue #114): true when one of `streams` already carries a fresh
269
+ # token by RE-RENDERING this component itself — so appending to_stream_token
270
+ # would double it. Target+root scoped (distinct from GUARD 2's global scan).
122
271
  #
123
- # The match requires BOTH (a) the stream's action re-renders the component's
124
- # ROOT (replace/update/reactive:token never append/prepend, which insert
125
- # children) AND (b) it targets the component's id AND (c) it actually carries
126
- # a token. This is stricter than a same-string substring check on purpose:
127
- # * A sibling component's replace targets a DIFFERENT id → (b) fails, so we
272
+ # A Phlex::Reactive::Stream answers STRUCTURALLY (rx_refreshes_token_for?
273
+ # the three-way test carries_token AND renders_root AND same target, no
274
+ # regex). A raw string / metadata-degraded object falls back to the legacy
275
+ # opening-tag regex, which encodes the same rule:
276
+ # * A sibling component's replace targets a DIFFERENT id → no match, so we
128
277
  # still refresh ours (issue #30).
129
278
  # * A reactive child row appended/prepended INTO the component carries its
130
- # own token at the component's target, but the action is append/prepend
131
- # (a) fails, so we still refresh the CONTAINER's token (issue #44). Before
132
- # this, the child's token at the container target suppressed the
133
- # container's refresh and the list was add-once-only.
279
+ # own token at the component's target, but append/prepend do NOT
280
+ # re-render the root → no match, so we still refresh the CONTAINER's
281
+ # token (issue #44). Before this, the child's token at the container
282
+ # target suppressed the container's refresh and the list was
283
+ # add-once-only.
134
284
  def carries_token_for?(streams, component)
135
- target = %(target="#{ERB::Util.html_escape(component.id)}")
136
- streams.any? do
137
- it.include?("data-reactive-token-value") &&
138
- it.include?(target) &&
139
- self_render_stream_for?(it, target)
285
+ streams.any? { stream_refreshes_token_for?(it, component.id) }
286
+ end
287
+
288
+ # Per-stream GUARD 1 predicate: a Stream with intact metadata answers
289
+ # structurally (rx_refreshes_token_for?); a raw string / metadata-degraded
290
+ # object falls back to the legacy opening-tag regex.
291
+ def stream_refreshes_token_for?(stream, component_id)
292
+ if stream.is_a?(Phlex::Reactive::Stream) && stream.rx_action
293
+ stream.rx_refreshes_token_for?(component_id)
294
+ else
295
+ legacy_self_render_token?(stream, component_id)
140
296
  end
141
297
  end
142
298
 
299
+ # LEGACY fallback for raw strings (reply.with) and metadata-degraded
300
+ # streams: the pre-#114 substring + opening-tag regex, kept verbatim as the
301
+ # floor beneath the structural path.
302
+ def legacy_self_render_token?(stream, component_id)
303
+ target = %(target="#{ERB::Util.html_escape(component_id)}")
304
+ stream.include?("data-reactive-token-value") &&
305
+ stream.include?(target) &&
306
+ self_render_stream_for?(stream, target)
307
+ end
308
+
143
309
  # Does this turbo-stream's OPENING tag re-render `target` itself? Matches a
144
310
  # `<turbo-stream action="<self-render>" ... target="<id>">` opening tag — the
145
311
  # action and target on the SAME tag — so a child row's token embedded in an
146
312
  # append/prepend `<template>` can never count as the container's own refresh.
313
+ # LEGACY: only the raw-string fallback reaches this now.
147
314
  def self_render_stream_for?(stream, target)
148
315
  open_tag = stream[/<turbo-stream\b[^>]*>/]
149
316
  return false unless open_tag&.include?(target)
@@ -170,7 +337,11 @@ module Phlex
170
337
 
171
338
  def verified_payload
172
339
  token = params.require(:token)
173
- Phlex::Reactive.verify(token) || raise(Phlex::Reactive::InvalidToken)
340
+ Phlex::Reactive.verify(token) || raise(Phlex::Reactive::InvalidToken.new(
341
+ "token signature invalid — stale token from before a deploy? secret_key_base mismatch? " \
342
+ "a reply.with(...) stream that skipped the token refresh?",
343
+ diagnostic: :tampered
344
+ ))
174
345
  end
175
346
 
176
347
  # NB: must NOT be named `action_name` — that's reserved by
@@ -179,158 +350,88 @@ module Phlex
179
350
  params.require(:act).to_sym
180
351
  end
181
352
 
182
- # Coerce client params against the action's declared schema. Anything not
353
+ # Coerce client params against the action's compiled schema (issue #109 —
354
+ # the coerce family now lives in Phlex::Reactive::ParamSchema). Anything not
183
355
  # in the schema is dropped — no raw mass assignment reaches the component.
184
- # The top-level params arrive as an ActionController::Parameters; coerce
185
- # them against the action's hash schema (same recursion as nested hashes).
186
- def coerce_params(schema)
187
- return {} if schema.blank?
188
-
189
- coerce_hash(params.fetch(:params, {}), schema)
190
- end
191
-
192
- # Sentinel: a declared key whose value can't be coerced to its type is
193
- # DROPPED (not assigned), so the method's keyword default applies — exactly
194
- # as if the client had omitted the key. Distinct from a coerced nil/[].
195
- DROP = Object.new
196
- private_constant :DROP
197
-
198
- # Coerce a value against a declared type. A type is one of:
199
- # * a scalar symbol (:string/:integer/:float/:boolean)
200
- # * :file — a multipart upload (issue #34)
201
- # * a Hash schema ({ id: :integer, ... }) — nested object
202
- # * a one-element Array ([:integer] / [{ ... }]) — array of that
203
- # Arrays accept both a real JSON array and a Rails-style index hash
204
- # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
205
- def coerce(value, type)
206
- case type
207
- when Array
208
- coerce_array(value, type.first)
209
- when Hash
210
- coerce_hash(value, type)
211
- when :file
212
- coerce_file(value)
213
- else
214
- coerce_scalar(value, type)
215
- end
216
- end
217
-
218
- # An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
219
- # which would corrupt it into a string the action can't attach. Anything
220
- # that isn't an uploaded file (a forged/malformed scalar, an empty input)
221
- # returns DROP, so the method's keyword default applies — consistent with
222
- # the #16 rule that a value that can't be coerced to its type is dropped,
223
- # not fabricated. Duck-types on UploadedFile's interface (original_filename
224
- # + a readable IO) rather than naming a class, so a Rack::Test upload, an
225
- # ActionDispatch upload, and a Falcon multipart body all qualify.
226
- def coerce_file(value)
227
- uploaded_file?(value) ? value : DROP
228
- end
229
-
230
- def uploaded_file?(value)
231
- value.respond_to?(:original_filename) && value.respond_to?(:read)
232
- end
233
-
234
- # A real array (or Rails index hash) coerces element-wise. A malformed
235
- # present-but-non-array value returns DROP rather than [] — coercing a stray
236
- # scalar to an empty array would let a bad payload read as an explicit
237
- # "clear everything" on update!(declared_array:).
238
356
  #
239
- # An ELEMENT that coerces to DROP (e.g. a non-file in a [:file] array, a
240
- # forged/mixed payload) is rejected from the result the same rule
241
- # coerce_hash applies to a dropped value, so the internal DROP sentinel
242
- # never leaks into the action. A genuinely empty input array stays [] (an
243
- # explicit empty collection), but an array whose every element drops
244
- # returns DROP, so the keyword default applies rather than handing the
245
- # action a surprise [].
246
- def coerce_array(value, element_type)
247
- values = array_values(value)
248
- return DROP if values.nil?
249
- return [] if values.empty?
250
-
251
- coerced = values.map { coerce(it, element_type) }
252
- coerced.reject! { it.equal?(DROP) }
253
- coerced.empty? ? DROP : coerced
254
- end
255
-
256
- # Keep declared keys only (drop undeclared — no mass assignment), recursing
257
- # for nested hash/array element types. Symbolizes keys to splat as kwargs.
258
- # A key whose value coerces to DROP is skipped (keyword default applies).
259
- def coerce_hash(value, schema)
260
- hash = to_param_hash(value)
261
- schema.each_with_object({}) do |(key, type), out|
262
- next unless hash.key?(key.to_s)
263
-
264
- coerced = coerce(hash[key.to_s], type)
265
- next if coerced.equal?(DROP)
266
-
267
- out[key.to_sym] = coerced
268
- end
357
+ # With verbose_errors on, ParamSchema fills a collector with every dropped
358
+ # key (full bracketed path + reason) and we warn-log it once per action.
359
+ # With the flag off the collector is nil and every diagnostic branch is
360
+ # skipped zero extra work on the production path.
361
+ def coerce_params(action_def, component_class: nil, action_name: nil)
362
+ dropped = Phlex::Reactive.verbose_errors ? [] : nil
363
+ raw = params.fetch(:params, {})
364
+
365
+ coerced = action_def.schema.coerce(raw, dropped)
366
+ log_dropped_params(dropped, action_def.params, component_class, action_name)
367
+ coerced
269
368
  end
270
369
 
271
- def coerce_scalar(value, type)
272
- case type
273
- when :integer then value.to_i
274
- when :float then value.to_f
275
- when :boolean then ActiveModel::Type::Boolean.new.cast(value)
276
- else value.to_s
277
- end
370
+ # ---- verbose_errors dropped-param logging --------------------------
371
+ # ParamSchema collects the dropped entries; the controller formats the ONE
372
+ # warn line (with the #16/#21 shape hints). Everything below runs ONLY when
373
+ # the collector exists (flag on); the production path never reaches it.
374
+
375
+ # ONE warn line per action naming every dropped param with its reason —
376
+ # plus, for the #16/#21 confusion, a hint when a dropped name looks like
377
+ # the flat/bracketed twin of a declared key. `schema` is the RAW declared
378
+ # hash (action_def.params), read only for the shape hints.
379
+ def log_dropped_params(dropped, schema, component_class, action_name)
380
+ return if dropped.nil? || dropped.empty?
381
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
382
+
383
+ entries = dropped.map { |path, reason| "#{path} (#{dropped_reason(path, reason, schema)})" }
384
+ where = [component_class, action_name].compact.join("#")
385
+ where = "#{where} " unless where.empty?
386
+ ::Rails.logger.warn("[phlex-reactive] #{where}dropped params: #{entries.join(", ")}")
278
387
  end
279
388
 
280
- # Normalize an array param: a real array passes through; a Rails index hash
281
- # ({ "0" => ..., "1" => ... }) becomes its values in index order. Anything
282
- # else (a stray scalar, nil) is malformed nil, so the caller drops the
283
- # param rather than fabricating an empty array.
284
- def array_values(value)
285
- return value.to_a if value.is_a?(Array)
389
+ def dropped_reason(path, reason, schema)
390
+ return reason.to_s unless reason == :undeclared
391
+ return "undeclared the action declares no params" if schema.empty?
286
392
 
287
- return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
288
-
289
- to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
393
+ hint = shape_hint(path, schema)
394
+ hint ? "undeclared — #{hint}" : "undeclared"
290
395
  end
291
396
 
292
- # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
293
- # Hash so coercion can index it uniformly, then expand bracket notation so
294
- # a model-scoped Rails form's flat keys nest (issue #21).
295
- def to_param_hash(value)
296
- flat =
297
- if value.respond_to?(:to_unsafe_h)
298
- value.to_unsafe_h.stringify_keys
299
- elsif value.is_a?(Hash)
300
- value.stringify_keys
301
- else
302
- return {}
303
- end
397
+ # Fires only when a dropped segment matches a DECLARED key at a different
398
+ # nesting level a bracketed name whose leaf the schema declares flat, or
399
+ # a flat name the schema declares one level down. Deliberately simple: it
400
+ # searches one nesting level (hash / array-of-hash), no deeper.
401
+ def shape_hint(path, schema)
402
+ segments = bracket_path(path)
403
+ if segments.length > 1
404
+ leaf = segments.last
405
+ return unless schema.key?(leaf.to_sym)
406
+
407
+ "schema declares :#{leaf} at top level; nested schemas look like " \
408
+ "{ #{segments.first}: { #{leaf}: :string } }"
409
+ else
410
+ parent = nested_declaration_of(segments.first, schema)
411
+ return unless parent
304
412
 
305
- expand_bracket_keys(flat)
413
+ "schema declares :#{segments.first} nested under :#{parent}; " \
414
+ "post it as #{parent}[#{segments.first}]"
415
+ end
306
416
  end
307
417
 
308
- # The client's #collectFields keeps a form input's name verbatim, so a
309
- # Rails Form(model: @invoice) posts FLAT bracketed keys like
310
- # "invoice[date]". Coercion does exact-key matching, so without this a
311
- # nested schema (params: { invoice: { date: … } }) never finds "invoice"
312
- # and drops everything. Expand "invoice[date]" => "2026-01-02" into
313
- # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
314
- # the Rails index-hash form coerce_array already understands — deep-merging
315
- # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
316
- # brackets and already-nested values pass through untouched, so a
317
- # pre-nested object (issue #16) and plain scalars still work. Value types
318
- # (a checkbox boolean, an explicit array) are preserved verbatim — unlike a
319
- # round-trip through parse_nested_query, which only handles strings.
320
- def expand_bracket_keys(flat)
321
- flat.each_with_object({}) do |(key, value), out|
322
- deep_assign(out, bracket_path(key), value)
323
- end
418
+ # The first schema key whose nested hash (or array-of-hash element
419
+ # schema) declares `name` one level down.
420
+ def nested_declaration_of(name, schema)
421
+ schema.find do |_key, type|
422
+ inner = type.is_a?(Array) ? type.first : type
423
+ inner.is_a?(Hash) && inner.key?(name.to_sym)
424
+ end&.first
324
425
  end
325
426
 
326
427
  # Matches each bracket segment in "items_attributes][0][qty]" — the part
327
- # after the first "[". Hoisted to a frozen constant so coercing a bracketed
328
- # key doesn't recompile the pattern per key on every request.
428
+ # after the first "[". Hoisted to a frozen constant so the shape-hint path
429
+ # (verbose only) doesn't recompile the pattern per call.
329
430
  BRACKET_SEGMENT = /[^\[\]]+/
330
431
  private_constant :BRACKET_SEGMENT
331
432
 
332
- # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
333
- # "0", "qty"]. A key with no brackets is a single-element path.
433
+ # "invoice[date]" => ["invoice", "date"]. A key with no brackets is a
434
+ # single-element path. Used only to shape the dropped-param hint.
334
435
  def bracket_path(key)
335
436
  return [key] unless key.include?("[")
336
437
 
@@ -338,35 +439,26 @@ module Phlex
338
439
  [head, *rest.scan(BRACKET_SEGMENT)]
339
440
  end
340
441
 
341
- # Walk/create nested hashes along `path`, then merge `value` at the leaf so
342
- # a bracket key and a sibling pre-nested object coalesce regardless of which
343
- # arrives first ({ "invoice[date]" => …, invoice: { status: … } } keeps
344
- # both). #merge_value deep-merges hash/hash collisions and otherwise lets
345
- # the later value win (a bracket key colliding with a flat scalar).
346
- def deep_assign(hash, path, value)
347
- *parents, leaf = path
348
- node = parents.reduce(hash) do |acc, segment|
349
- acc[segment] = {} unless acc[segment].is_a?(Hash)
350
- acc[segment]
351
- end
352
- node[leaf] = merge_value(node[leaf], value)
353
- end
354
-
355
- # Combine an existing leaf value with a new one. Two hashes deep-merge (so
356
- # bracket-expanded fields and a pre-nested object for the same key both
357
- # survive); any other collision takes the new value.
358
- def merge_value(existing, value)
359
- return value unless existing.is_a?(Hash) && value.is_a?(Hash)
360
-
361
- existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
362
- end
442
+ # ---- end verbose_errors logging ------------------------------------
363
443
 
364
444
  # Only components that opt into Reactive may be resolved. The signature
365
- # already gates this; defense in depth against constant injection.
445
+ # already gates this; defense in depth against constant injection. The
446
+ # two failure causes carry distinct diagnostics: a name that doesn't
447
+ # resolve at all vs a constant that resolved but isn't a reactive
448
+ # component.
366
449
  def resolve_component(name)
367
450
  klass = name.to_s.safe_constantize
451
+ unless klass
452
+ raise Phlex::Reactive::InvalidToken.new(
453
+ "token class #{name} does not resolve — component renamed/removed while a page was open?",
454
+ diagnostic: :unknown_class
455
+ )
456
+ end
368
457
  unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
369
- raise Phlex::Reactive::InvalidToken
458
+ raise Phlex::Reactive::InvalidToken.new(
459
+ "#{name} resolved but does not include Phlex::Reactive::Component",
460
+ diagnostic: :not_reactive_class
461
+ )
370
462
  end
371
463
 
372
464
  klass