phlex-reactive 0.4.8 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +868 -3
- 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 +222 -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 +156 -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 +329 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
120
|
-
# this component itself —
|
|
121
|
-
#
|
|
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
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
# * A sibling component's replace targets a DIFFERENT id →
|
|
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
|
|
131
|
-
#
|
|
132
|
-
# this, the child's token at the container
|
|
133
|
-
# container's refresh and the list was
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
293
|
-
#
|
|
294
|
-
# a
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
413
|
+
"schema declares :#{segments.first} nested under :#{parent}; " \
|
|
414
|
+
"post it as #{parent}[#{segments.first}]"
|
|
415
|
+
end
|
|
306
416
|
end
|
|
307
417
|
|
|
308
|
-
# The
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
328
|
-
#
|
|
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[
|
|
333
|
-
#
|
|
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
|
-
#
|
|
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
|
|
@@ -20,17 +20,62 @@
|
|
|
20
20
|
// allowance, leasing, cash: total - allowance - leasing,
|
|
21
21
|
// }))
|
|
22
22
|
//
|
|
23
|
-
// The reducer
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
23
|
+
// The reducer's signature is (values, meta):
|
|
24
|
+
//
|
|
25
|
+
// values — a plain object of { inputName: value } over the declared inputs.
|
|
26
|
+
// Untyped inputs (the array form) AND :number-typed inputs arrive as
|
|
27
|
+
// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,
|
|
28
|
+
// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:
|
|
29
|
+
// { title: :string, qty: :number }` is what selects per-input types;
|
|
30
|
+
// `inputs: %i[a b]` stays all-numeric (backward compatible).
|
|
31
|
+
// meta — { changed }: the name (string) of the declared input the
|
|
32
|
+
// triggering event edited, or null (a direct recompute() call, or a
|
|
33
|
+
// target this root doesn't own / didn't declare as an input).
|
|
34
|
+
//
|
|
35
|
+
// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name
|
|
36
|
+
// matches an owned control writes its .value (+ the change-guarded input
|
|
37
|
+
// dispatch below). An output with NO matching field writes textContent to every
|
|
38
|
+
// owned [data-reactive-text="<name>"] node (reactive_text(:name)) — XSS-safe by
|
|
39
|
+
// construction, change-guarded, NO input dispatch (a text node has no listener
|
|
40
|
+
// contract). A declared INPUT also mirrors into its own text node on every
|
|
41
|
+
// input via an always-run pass — so reactive_text(:title) is a live field
|
|
42
|
+
// preview with NO registered reducer at all.
|
|
43
|
+
//
|
|
44
|
+
// It returns a plain object of { outputName: value } — only the outputs it
|
|
45
|
+
// names are written, so it can leave the edited field (and its caret)
|
|
46
|
+
// untouched. A one-argument reducer keeps working unchanged (it just ignores
|
|
47
|
+
// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as
|
|
48
|
+
// one reducer (issue #75) — branch on which field the user edited:
|
|
49
|
+
//
|
|
50
|
+
// setComputeReducer("three_way_split", ({ field_a, field_b, field_c, total }, { changed }) => {
|
|
51
|
+
// if (changed === "field_c") return { field_a: total - field_c - field_b }
|
|
52
|
+
// return { field_c: total - field_a - field_b }
|
|
53
|
+
// })
|
|
54
|
+
//
|
|
55
|
+
// Output writes are CHANGE-GUARDED: the controller writes a field and
|
|
56
|
+
// dispatches a bubbling `input` event on it ONLY when the new value differs
|
|
57
|
+
// from the field's current value (real browsers never fire `input` on a
|
|
58
|
+
// programmatic .value write, so the controller dispatches explicitly — that's
|
|
59
|
+
// what drives a chained summary repaint, matching the server's set_value +
|
|
60
|
+
// dispatch("input") contract). Returning the SAME value a field already holds
|
|
61
|
+
// is skipped entirely — no write, no event — which is why a reducer with
|
|
62
|
+
// overlapping inputs/outputs (like payment_split above) settles instead of
|
|
63
|
+
// re-entering itself forever.
|
|
64
|
+
//
|
|
65
|
+
// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input
|
|
66
|
+
// event (issue #76), recompute re-enters with changed = that OUTPUT field's
|
|
67
|
+
// name (when it's also a declared input). A branching reducer must therefore
|
|
68
|
+
// be convergent: the re-entrant pass must compute values EQUAL to what the
|
|
69
|
+
// first pass already wrote to the DOM, so the change guard settles the chain.
|
|
70
|
+
// The three_way_split above is: after `changed === "field_a"` writes field_c,
|
|
71
|
+
// the re-entrant `changed === "field_c"` pass derives field_a back to the
|
|
72
|
+
// value it already holds — no write, no event, settled in one bounce.
|
|
29
73
|
|
|
30
74
|
const reducers = new Map()
|
|
31
75
|
|
|
32
76
|
// Register (or replace) the reducer for `key`. `fn` is
|
|
33
|
-
// (values: Record<string, number
|
|
77
|
+
// (values: Record<string, number>, meta: { changed: string | null })
|
|
78
|
+
// => Record<string, unknown>.
|
|
34
79
|
export function setComputeReducer(key, fn) {
|
|
35
80
|
reducers.set(key, fn)
|
|
36
81
|
}
|