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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -3
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  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 +1487 -80
  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 +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  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 +19 -1
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ # Opt-in LogSubscriber (issue #107): turns each hot-path event into ONE
8
+ # compact debug line, so a developer can watch reactive traffic in the log
9
+ # without an APM:
10
+ #
11
+ # [reactive] Counter#increment ok (3.1ms)
12
+ # [reactive] Counter#drop_table denied_undeclared (0.2ms)
13
+ # [reactive] render Counter 512B (0.9ms)
14
+ # [reactive] broadcast replace Counter →2 (1.4ms)
15
+ #
16
+ # Default OFF — the events fire for APMs regardless of this; attaching the
17
+ # subscriber only adds the gem's own log lines. The engine attaches it once
18
+ # at boot when Phlex::Reactive.log_events is true. Lines are logged at DEBUG,
19
+ # so they're invisible unless the app's log level allows it.
20
+ #
21
+ # Payloads carry NAMES/outcome/sizes ONLY (never token/params/state), so
22
+ # these lines can never leak a secret. A :invalid_token event has no trusted
23
+ # component name (the token didn't verify), so the line omits the `Class#`
24
+ # prefix rather than echo an unverified class.
25
+ class LogSubscriber < ::ActiveSupport::LogSubscriber
26
+ def action(event)
27
+ return unless logger.debug?
28
+
29
+ payload = event.payload
30
+ subject =
31
+ if payload[:component]
32
+ "#{payload[:component]}##{payload[:action]}"
33
+ else
34
+ # No trusted component (invalid token) — name the action alone.
35
+ payload[:action]
36
+ end
37
+ debug { "[reactive] #{subject} #{payload[:outcome]} (#{round(event.duration)}ms)" }
38
+ end
39
+
40
+ def render(event)
41
+ return unless logger.debug?
42
+
43
+ payload = event.payload
44
+ debug { "[reactive] render #{payload[:component]} #{payload[:bytesize]}B (#{round(event.duration)}ms)" }
45
+ end
46
+
47
+ def broadcast(event)
48
+ return unless logger.debug?
49
+
50
+ payload = event.payload
51
+ debug do
52
+ "[reactive] broadcast #{payload[:stream_action]} #{payload[:component]} " \
53
+ "→#{payload[:streamables]} (#{round(event.duration)}ms)"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def round(duration_ms)
60
+ duration_ms.round(1)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "bigdecimal"
6
+ require "active_model"
7
+ require "active_model/type"
8
+
9
+ module Phlex
10
+ module Reactive
11
+ # A COMPILED param schema (issue #109). The coerce family used to live inline
12
+ # in ActionsController; here it's a standalone unit built ONCE at declaration
13
+ # (Component.action -> ParamSchema.compile) so:
14
+ #
15
+ # * an unknown type symbol raises Phlex::Reactive::UnknownParamType at class
16
+ # load instead of silently `to_s`-ing at request time, and
17
+ # * the drop-don't-fabricate coercion is unit-testable without a booted
18
+ # request spec.
19
+ #
20
+ # The behavior is byte-for-byte what the controller did: the built-ins keep
21
+ # their exact semantics ("abc".to_i => 0, not DROP; :file duck-type DROP; the
22
+ # array/hash DROP rules), the bracket-expansion/deep-merge matrix (#16/#21/
23
+ # #24/#39) is UNCHANGED, and the verbose_errors dropped-param collector (#82/
24
+ # #87) threads through unchanged — a nil collector is the zero-cost prod path.
25
+ #
26
+ # A type is one of:
27
+ # * a scalar symbol (:string/:integer/:float/:boolean/:file/:date/
28
+ # :datetime/:decimal, or an app-registered type)
29
+ # * a Hash schema ({ id: :integer, ... }) — nested object
30
+ # * a one-element Array ([:integer] / [{ ... }]) — array of that
31
+ class ParamSchema
32
+ # Sentinel: a declared key whose value can't be coerced to its type is
33
+ # DROPPED (not assigned), so the method's keyword default applies — exactly
34
+ # as if the client had omitted the key. Distinct from a coerced nil/[]. A
35
+ # custom param_type callable returns this to reject a value while keeping
36
+ # the drop-don't-fabricate contract, so it is PUBLIC.
37
+ DROP = Object.new
38
+ DROP.freeze
39
+
40
+ class << self
41
+ # Validate `schema` recursively against the param-type registry and
42
+ # return a compiled ParamSchema. Raises UnknownParamType at the FIRST
43
+ # unknown type symbol (naming the full bracketed path), so a typo fails
44
+ # loudly at declaration. A blank schema compiles to an empty schema (the
45
+ # action drops everything the client posts).
46
+ def compile(schema)
47
+ schema = {} if schema.nil?
48
+ validate!(schema, nil)
49
+ new(schema)
50
+ end
51
+
52
+ # The frozen-semantics built-in registry, rebuilt on demand (the module
53
+ # dups it so app registration doesn't mutate this template). Each entry
54
+ # is a callable(value) -> coerced | DROP. The scalar casts here are the
55
+ # EXACT ones the controller ran, so the existing request-spec matrix is
56
+ # unchanged.
57
+ # rubocop:disable Style/ItBlockParameter, Style/SymbolProc
58
+ # Explicit `(value)` params, NOT `it` or `&:to_s`: the date/datetime/decimal
59
+ # casters wrap an INNER `parse_or_drop { ... }` block, and `it` there would
60
+ # bind to that inner block's (absent) param, not the caster's value — the
61
+ # #109 autocorrect trap. Keep the WHOLE family explicit so it reads
62
+ # uniformly and no caster can be collapsed to a zero-arity lambda.
63
+ def built_in_types
64
+ {
65
+ string: ->(value) { value.to_s },
66
+ integer: ->(value) { value.to_i },
67
+ float: ->(value) { value.to_f },
68
+ boolean: ->(value) { BOOLEAN_TYPE.cast(value) },
69
+ # :file and the composite types (Hash/Array) are handled structurally
70
+ # in #coerce, not via a scalar callable — the registry entry exists so
71
+ # compile-time validation recognizes the symbol. A nil callable means
72
+ # "handled structurally"; #coerce never dispatches it here.
73
+ file: nil,
74
+ date: ->(value) { parse_or_drop { Date.iso8601(value.to_s) } },
75
+ datetime: ->(value) { parse_or_drop { DateTime.iso8601(value.to_s) } },
76
+ decimal: ->(value) { parse_or_drop { BigDecimal(value.to_s) } }
77
+ }
78
+ end
79
+ # rubocop:enable Style/ItBlockParameter, Style/SymbolProc
80
+
81
+ private
82
+
83
+ # Run a parse that raises on bad input (Date/DateTime iso8601, BigDecimal)
84
+ # and turn the failure into DROP — the keyword default then applies rather
85
+ # than a fabricated value. ::ArgumentError covers BigDecimal("abc") and a
86
+ # blank/garbage string; ::Date::Error (an ArgumentError subclass) covers
87
+ # the iso8601 parsers; ::TypeError covers a nil slipping through.
88
+ #
89
+ # MUST be top-level-qualified (::): the phlex gem defines Phlex::Argument
90
+ # Error (a subclass of the stdlib one), and this file is nested under
91
+ # Phlex::Reactive, so a bare `ArgumentError` resolves LEXICALLY to
92
+ # Phlex::ArgumentError — which would NOT catch a stdlib Date::Error and the
93
+ # parse failure would escape as a 500.
94
+ def parse_or_drop
95
+ yield
96
+ rescue ::ArgumentError, ::TypeError
97
+ DROP
98
+ end
99
+
100
+ # Walk the schema, validating each type symbol against the registry and
101
+ # recursing into nested hashes and array element types. `path` is the
102
+ # bracketed name for a helpful error message.
103
+ def validate!(schema, path)
104
+ schema.each do |key, type|
105
+ validate_type!(type, join(path, key))
106
+ end
107
+ end
108
+
109
+ def validate_type!(type, path)
110
+ case type
111
+ when Array
112
+ unless type.length == 1
113
+ raise UnknownParamType, "#{path}: an array type must wrap exactly one element type, " \
114
+ "got #{type.inspect}"
115
+ end
116
+
117
+ validate_type!(type.first, "#{path}[]")
118
+ when Hash
119
+ validate!(type, path)
120
+ when Symbol
121
+ unless Phlex::Reactive.param_type?(type)
122
+ raise UnknownParamType, "#{path}: unknown param type #{type.inspect}. Known types: " \
123
+ "#{Phlex::Reactive.param_types.keys.sort.join(", ")}. " \
124
+ "Register a custom type with Phlex::Reactive.param_type(#{type.inspect}) { |v| ... }."
125
+ end
126
+ else
127
+ raise UnknownParamType, "#{path}: a param type must be a Symbol, a Hash schema, or a " \
128
+ "one-element Array, got #{type.inspect}"
129
+ end
130
+ end
131
+
132
+ def join(path, key)
133
+ path ? "#{path}[#{key}]" : key.to_s
134
+ end
135
+ end
136
+
137
+ # ActiveModel's Boolean type, instantiated once — the exact caster the
138
+ # controller used (an unrecognized value casts to nil, which is KEPT, not
139
+ # dropped).
140
+ BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
141
+ private_constant :BOOLEAN_TYPE
142
+
143
+ attr_reader :schema
144
+
145
+ def initialize(schema)
146
+ @schema = schema
147
+ end
148
+
149
+ # Coerce client params against this schema. Anything not in the schema is
150
+ # dropped — no raw mass assignment reaches the component. The top-level
151
+ # params (an ActionController::Parameters or a plain Hash) coerce against
152
+ # the hash schema (same recursion as nested hashes).
153
+ #
154
+ # `dropped` is the verbose_errors diagnostics collector: an Array that
155
+ # accumulates [bracketed_path, :undeclared|:uncoercible] entries. Pass nil
156
+ # (the production path) and every diagnostic branch early-returns — zero
157
+ # extra work.
158
+ def coerce(params, dropped = nil)
159
+ # A schema-less action drops EVERYTHING the client posted. Under a
160
+ # verbose collector, record every posted key as :undeclared (forgetting
161
+ # `params:` entirely is the loudest silent drop — #87); otherwise it's a
162
+ # cost-free {} return on the production path.
163
+ if @schema.empty?
164
+ collect_undeclared(dropped, to_param_hash(params), {}, nil) if dropped
165
+ return {}
166
+ end
167
+
168
+ # The TOP-LEVEL params container is the request's `params[:params]` — a
169
+ # coerced-away malformed top level (a stray non-hash) is normalized back
170
+ # to {} here, never DROP: the whole point of the top level is to hold the
171
+ # coerced kwargs, so a bad container yields "no params", not a dropped
172
+ # action argument. Nested malformed hashes DO drop (see coerce_hash).
173
+ coerced = coerce_hash(params, @schema, dropped, nil)
174
+ coerced.equal?(DROP) ? {} : coerced
175
+ end
176
+
177
+ private
178
+
179
+ # Coerce a value against a declared type. Arrays accept both a real JSON
180
+ # array and a Rails-style index hash ({ "0" => ..., "1" => ... }), so a
181
+ # fields_for collection works either way.
182
+ def coerce_value(value, type, dropped, path)
183
+ case type
184
+ when Array
185
+ coerce_array(value, type.first, dropped, path)
186
+ when Hash
187
+ coerce_hash(value, type, dropped, path)
188
+ when :file
189
+ coerce_file(value)
190
+ else
191
+ coerce_scalar(value, type)
192
+ end
193
+ end
194
+
195
+ # Dispatch a scalar through the registry callable. The symbol is known
196
+ # (compile validated it), so the lookup always hits.
197
+ def coerce_scalar(value, type)
198
+ Phlex::Reactive.param_types.fetch(type).call(value)
199
+ end
200
+
201
+ # An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
202
+ # which would corrupt it. Anything that isn't an uploaded file returns DROP
203
+ # (the keyword default applies). Duck-types on UploadedFile's interface
204
+ # rather than naming a class, so a Rack::Test upload, an ActionDispatch
205
+ # upload, and a Falcon multipart body all qualify.
206
+ def coerce_file(value)
207
+ uploaded_file?(value) ? value : DROP
208
+ end
209
+
210
+ def uploaded_file?(value)
211
+ value.respond_to?(:original_filename) && value.respond_to?(:read)
212
+ end
213
+
214
+ # A real array (or Rails index hash) coerces element-wise. A malformed
215
+ # present-but-non-array value returns DROP rather than [] — coercing a stray
216
+ # scalar to an empty array would let a bad payload read as an explicit
217
+ # "clear everything". An ELEMENT that coerces to DROP is rejected; an array
218
+ # whose every element drops returns DROP (keyword default applies), but a
219
+ # genuinely empty input array stays [].
220
+ def coerce_array(value, element_type, dropped, path)
221
+ values = array_values(value)
222
+ return DROP if values.nil?
223
+ return [] if values.empty?
224
+
225
+ coerced =
226
+ if dropped
227
+ values.map.with_index do |element, i|
228
+ element_path = "#{path}[#{i}]"
229
+ result = coerce_value(element, element_type, dropped, element_path)
230
+ dropped << [element_path, :uncoercible] if result.equal?(DROP)
231
+ result
232
+ end
233
+ else
234
+ values.map { coerce_value(it, element_type, nil, nil) }
235
+ end
236
+ coerced.reject! { it.equal?(DROP) }
237
+ coerced.empty? ? DROP : coerced
238
+ end
239
+
240
+ # Keep declared keys only (drop undeclared — no mass assignment), recursing
241
+ # for nested hash/array element types. Symbolizes keys to splat as kwargs.
242
+ # A key whose value coerces to DROP is skipped (keyword default applies).
243
+ #
244
+ # A present-but-malformed value (a nested `invoice: null` / `invoice: "x"`
245
+ # for a hash schema) returns DROP rather than fabricating {} — the same
246
+ # drop-don't-fabricate rule coerce_array applies to a non-array, so a bad
247
+ # payload can't read as an explicit empty object on update!(nested:). The
248
+ # top-level entry (coerce) normalizes that DROP back to {}. A genuinely
249
+ # empty hash ({} / an empty index form) stays {}.
250
+ def coerce_hash(value, schema, dropped, path)
251
+ return DROP unless hash_like?(value)
252
+
253
+ hash = to_param_hash(value)
254
+ collect_undeclared(dropped, hash, schema, path) if dropped
255
+ schema.each_with_object({}) do |(key, type), out|
256
+ key_name = key.to_s
257
+ next unless hash.key?(key_name)
258
+
259
+ key_path = dropped && join_path(path, key_name)
260
+ coerced = coerce_value(hash[key_name], type, dropped, key_path)
261
+ if coerced.equal?(DROP)
262
+ dropped << [key_path, :uncoercible] if dropped
263
+ next
264
+ end
265
+
266
+ out[key.to_sym] = coerced
267
+ end
268
+ end
269
+
270
+ # ---- verbose_errors dropped-param diagnostics ----------------------
271
+ # Everything below runs ONLY when the collector exists (flag on); the
272
+ # production path never reaches these methods.
273
+
274
+ def join_path(path, key)
275
+ path ? "#{path}[#{key}]" : key
276
+ end
277
+
278
+ # Record every input key this schema level doesn't declare. A hash value
279
+ # expands to its leaf paths so the log shows the full bracketed name the
280
+ # client posted (invoice[date], not just invoice).
281
+ def collect_undeclared(dropped, hash, schema, path)
282
+ declared = schema.keys.map(&:to_s)
283
+ hash.each do |key, value|
284
+ next if declared.include?(key)
285
+
286
+ record_undeclared(dropped, join_path(path, key), value)
287
+ end
288
+ end
289
+
290
+ def record_undeclared(dropped, path, value)
291
+ if value.is_a?(Hash) && value.any?
292
+ value.each { |key, child| record_undeclared(dropped, "#{path}[#{key}]", child) }
293
+ else
294
+ dropped << [path, :undeclared]
295
+ end
296
+ end
297
+
298
+ # ---- end verbose_errors diagnostics --------------------------------
299
+
300
+ # Normalize an array param: a real array passes through; a Rails index hash
301
+ # ({ "0" => ..., "1" => ... }) becomes its values in index order. Anything
302
+ # else (a stray scalar, nil) is malformed => nil, so the caller drops the
303
+ # param rather than fabricating an empty array.
304
+ def array_values(value)
305
+ return value.to_a if value.is_a?(Array)
306
+
307
+ return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
308
+
309
+ to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
310
+ end
311
+
312
+ # True when `value` is a Hash or an ActionController::Parameters — i.e. a
313
+ # value to_param_hash unwraps to real keys rather than {}. A nested hash
314
+ # schema fed anything else (nil, a scalar) is malformed and drops. The
315
+ # SAME acceptance test to_param_hash uses, kept as a guard so a malformed
316
+ # input is dropped BEFORE to_param_hash flattens it to a fabricated {}.
317
+ def hash_like?(value)
318
+ value.is_a?(Hash) || value.respond_to?(:to_unsafe_h)
319
+ end
320
+
321
+ # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
322
+ # Hash so coercion can index it uniformly, then expand bracket notation so
323
+ # a model-scoped Rails form's flat keys nest (issue #21).
324
+ def to_param_hash(value)
325
+ flat =
326
+ if value.respond_to?(:to_unsafe_h)
327
+ value.to_unsafe_h.stringify_keys
328
+ elsif value.is_a?(Hash)
329
+ value.stringify_keys
330
+ else
331
+ return {}
332
+ end
333
+
334
+ expand_bracket_keys(flat)
335
+ end
336
+
337
+ # The client's #collectFields keeps a form input's name verbatim, so a
338
+ # Rails Form(model: @invoice) posts FLAT bracketed keys like
339
+ # "invoice[date]". Coercion does exact-key matching, so without this a
340
+ # nested schema (params: { invoice: { date: … } }) never finds "invoice"
341
+ # and drops everything. Expand "invoice[date]" => "2026-01-02" into
342
+ # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
343
+ # the Rails index-hash form coerce_array already understands — deep-merging
344
+ # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
345
+ # brackets and already-nested values pass through untouched.
346
+ def expand_bracket_keys(flat)
347
+ flat.each_with_object({}) do |(key, value), out|
348
+ deep_assign(out, bracket_path(key), value)
349
+ end
350
+ end
351
+
352
+ # Matches each bracket segment in "items_attributes][0][qty]" — the part
353
+ # after the first "[". Hoisted to a frozen constant so coercing a bracketed
354
+ # key doesn't recompile the pattern per key on every request.
355
+ BRACKET_SEGMENT = /[^\[\]]+/
356
+ private_constant :BRACKET_SEGMENT
357
+
358
+ # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
359
+ # "0", "qty"]. A key with no brackets is a single-element path.
360
+ def bracket_path(key)
361
+ return [key] unless key.include?("[")
362
+
363
+ head, rest = key.split("[", 2)
364
+ [head, *rest.scan(BRACKET_SEGMENT)]
365
+ end
366
+
367
+ # Walk/create nested hashes along `path`, then merge `value` at the leaf so
368
+ # a bracket key and a sibling pre-nested object coalesce regardless of which
369
+ # arrives first. #merge_value deep-merges hash/hash collisions and otherwise
370
+ # lets the later value win.
371
+ def deep_assign(hash, path, value)
372
+ *parents, leaf = path
373
+ node = parents.reduce(hash) do |acc, segment|
374
+ acc[segment] = {} unless acc[segment].is_a?(Hash)
375
+ acc[segment]
376
+ end
377
+ node[leaf] = merge_value(node[leaf], value)
378
+ end
379
+
380
+ # Combine an existing leaf value with a new one. Two hashes deep-merge (so
381
+ # bracket-expanded fields and a pre-nested object for the same key both
382
+ # survive); any other collision takes the new value.
383
+ def merge_value(existing, value)
384
+ return value unless existing.is_a?(Hash) && value.is_a?(Hash)
385
+
386
+ existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
387
+ end
388
+ end
389
+ end
390
+ end
@@ -48,9 +48,11 @@ module Phlex
48
48
  Response.morph(@component)
49
49
  end
50
50
 
51
- # Morph only inner HTML (preserves the root element + its token attr).
52
- def update
53
- Response.update(@component)
51
+ # Update only inner HTML (preserves the root element + its token attr).
52
+ # `morph: true` morphs the inner HTML in place (issue #113) so a
53
+ # cross-tab/per-field update keeps a focused input's caret.
54
+ def update(morph: false)
55
+ Response.update(@component, morph:)
54
56
  end
55
57
 
56
58
  # Reactive collections (issue #35) — add/remove a row in a declared