phlex-reactive 0.10.0 → 0.11.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.
@@ -53,6 +53,8 @@ module Phlex
53
53
  record_key: :@reactive_own_record_key,
54
54
  # Form-field namespace — reactive_scope (issue #180).
55
55
  scope: :@reactive_own_scope,
56
+ # Dirty-tracking config — reactive_dirty (issue #184).
57
+ dirty: :@reactive_own_dirty,
56
58
  # Deferred reply segments — reactive_lazy (issue #165).
57
59
  lazy: :@reactive_own_lazy,
58
60
  # verify_authorized opt-out (issue #168): a scalar bare flag (skip the
@@ -124,7 +126,10 @@ module Phlex
124
126
  fetch(klass, kind) do
125
127
  inherited = inherited_value(klass, reader)
126
128
  own = own(klass, kind)
127
- (inherited || EMPTY_HASH).merge(own || EMPTY_HASH)
129
+ # FROZEN (issue #186): the resolved hash is the public dispatch table
130
+ # (reactive_actions[:x] is the default-deny source of truth) — handing
131
+ # out a mutable memo would let a caller corrupt it. Mutation raises.
132
+ (inherited || EMPTY_HASH).merge(own || EMPTY_HASH).freeze
128
133
  end
129
134
  end
130
135
 
@@ -6,9 +6,9 @@ module Phlex
6
6
  # reactive unit: declare actions in Ruby, and the generic `reactive`
7
7
  # Stimulus controller wires clicks/inputs to an HTTP round trip that
8
8
  # re-renders the component and applies it back into the DOM (a plain replace
9
- # by default; return Response.morph(self) to morph in place and keep the
10
- # focused input — issue #28). No per-feature Stimulus controllers, no
11
- # hand-picked Turbo targets.
9
+ # by default; return reply.morph to morph in place and keep the focused
10
+ # input — issue #28). No per-feature Stimulus controllers, no hand-picked
11
+ # Turbo targets.
12
12
  #
13
13
  # Including Component pulls in Phlex::Reactive::Streamable automatically
14
14
  # (Concern dependency — Streamable lands on the base first, exactly the old
@@ -309,7 +309,7 @@ data-reactive-defer-pending="true" aria-busy="true">#{inner}</div>)
309
309
  end
310
310
 
311
311
  # Resolve the segment's placeholder to inner HTML — nil means "no
312
- # shell". Same content contract as flash/also_update: a Phlex component
312
+ # shell". Same content contract as flash/also: a Phlex component
313
313
  # renders through the configured renderer (auto-escaped), a plain
314
314
  # String is DATA and gets escaped, an html_safe String is intentional
315
315
  # markup and passes verbatim (ERB::Util.html_escape's own contract).
@@ -32,7 +32,7 @@ module Phlex
32
32
  # — it raises loudly and broadcasts NOTHING, OUTSIDE the render rescue
33
33
  # below (a component with actions can't be forged past the signed
34
34
  # identity anyway, so this never fires for a real defer).
35
- unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
35
+ unless klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
36
36
  raise ::ArgumentError,
37
37
  "#{component_class_name} is not a reactive component — refusing the deferred render"
38
38
  end
@@ -57,7 +57,7 @@ module Phlex
57
57
  component = klass.from_identity(payload)
58
58
  return broadcast_cleanup(stream_key, target) if component.respond_to?(:render?) && !component.render?
59
59
 
60
- stream = morph ? component.to_stream_morph : component.to_stream_replace
60
+ stream = component.to_stream_replace(morph:)
61
61
  broadcast_payload(stream_key, stream.to_s + teardown_stream(target))
62
62
  rescue ::StandardError => e
63
63
  log_deferred_failure(e) unless e.is_a?(::ActiveRecord::RecordNotFound)
@@ -35,6 +35,8 @@ module Phlex
35
35
  phlex/reactive/reactive_controller.min.js.map
36
36
  phlex/reactive/confirm.min.js
37
37
  phlex/reactive/confirm.min.js.map
38
+ phlex/reactive/confirm_predicate.min.js
39
+ phlex/reactive/confirm_predicate.min.js.map
38
40
  phlex/reactive/compute.min.js
39
41
  phlex/reactive/compute.min.js.map
40
42
  phlex/reactive/inspect.min.js
@@ -76,6 +78,16 @@ module Phlex
76
78
  to: "phlex/reactive/compute.min.js",
77
79
  preload: true
78
80
  )
81
+ # The client-side confirm-predicate registry (issue #179) — the
82
+ # multi-field escape hatch for conditional confirmation. Same bare-specifier
83
+ # rationale as confirm/compute: reactive_controller.js imports it, and an app
84
+ # registers predicates via `import { setConfirmPredicate } from
85
+ # "phlex/reactive/confirm_predicate"` — both resolve through this pin.
86
+ it.importmap.pin(
87
+ "phlex/reactive/confirm_predicate",
88
+ to: "phlex/reactive/confirm_predicate.min.js",
89
+ preload: true
90
+ )
79
91
  # The on-demand client inspector (issue #168). NOT preloaded — it is a
80
92
  # dev/debugging tool loaded only when you `import("phlex/reactive/inspect")`
81
93
  # from the console, so no page pays for it. The pin makes that dynamic
@@ -116,6 +128,8 @@ module Phlex
116
128
  # frozen registry makes runtime registration a loud error rather than a
117
129
  # never-validated type. Idempotent.
118
130
  Phlex::Reactive.freeze_param_types!
131
+ # Freeze the named-schema registry the same way (issue #184).
132
+ Phlex::Reactive.freeze_param_schemas!
119
133
  end
120
134
  end
121
135
  end
@@ -54,6 +54,19 @@ module Phlex
54
54
  # gets the SAME server-side default-deny as the JS chain (defense in depth;
55
55
  # the client also enforces it). Non-attr ops and malformed entries pass
56
56
  # through untouched — the client interpreter default-denies unknown ops.
57
+ # :root -> the sentinel; a String passes through as a CSS selector. Shared
58
+ # by the js op builder AND the on() pending-state hint normalizer (issue
59
+ # #181) so BOTH resolve a `to:` target through one code path. Anything else
60
+ # (a stray symbol, nil) is a bug at the call site — raise at render time
61
+ # instead of silently matching nothing in the browser.
62
+ def self.normalize_target(to)
63
+ return ROOT_SENTINEL if to == :root
64
+ return to if to.is_a?(String)
65
+
66
+ raise ArgumentError,
67
+ "target must be :root or a CSS selector string, got #{to.inspect}"
68
+ end
69
+
57
70
  def self.assert_ops_allowed!(list)
58
71
  Array(list).each do |op, args|
59
72
  next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)
@@ -227,15 +240,23 @@ module Phlex
227
240
  self.class.assert_allowed_attr(name)
228
241
  end
229
242
 
230
- # A transition must be exactly [during, from, to] class lists (strings).
243
+ # A transition is NAMED legs { during:, from:, to: } (issue #186), compiled to
244
+ # the [during, from, to] wire array (zero client change). The old positional
245
+ # Array form is removed — it raises with the caller's OWN values slotted into
246
+ # the named form, so the rewrite is copy-pasteable.
231
247
  def normalize_transition(transition)
232
- list = Array(transition)
233
- unless list.size == 3
248
+ if transition.is_a?(Array)
249
+ d, f, t = transition
234
250
  raise ArgumentError,
235
- "#{self.class}: transition: must be [during, from, to] class lists, got #{transition.inspect}"
251
+ "#{self.class}: transition: takes named legs (issue #186) " \
252
+ "transition: { during: #{d.inspect}, from: #{f.inspect}, to: #{t.inspect} }"
253
+ end
254
+ unless transition.is_a?(Hash) && %i[during from to].all? { transition.key?(it) }
255
+ raise ArgumentError,
256
+ "#{self.class}: transition: must name during:, from:, to: class lists, got #{transition.inspect}"
236
257
  end
237
258
 
238
- list.map(&:to_s).freeze
259
+ [transition[:during], transition[:from], transition[:to]].map(&:to_s).freeze
239
260
  end
240
261
 
241
262
  def class_args(to, classes, global:)
@@ -248,16 +269,9 @@ module Phlex
248
269
  args.freeze
249
270
  end
250
271
 
251
- # :root -> the sentinel; a String passes through as a CSS selector.
252
- # Anything else (a stray symbol, nil) is a bug at the call site — raise at
253
- # render time instead of silently matching nothing in the browser.
254
- def normalize_target(to)
255
- return ROOT_SENTINEL if to == :root
256
- return to if to.is_a?(String)
257
-
258
- raise ArgumentError,
259
- "#{self.class}: target must be :root or a CSS selector string, got #{to.inspect}"
260
- end
272
+ # Instance-side alias for the class method (used by the op builder). See
273
+ # JS.normalize_target the shared :root/selector translation.
274
+ def normalize_target(to) = self.class.normalize_target(to)
261
275
  end
262
276
  end
263
277
  end
@@ -2,35 +2,25 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- # A component-bound facade over Response — the surface an action body uses to
6
- # control its reply. Component#reply returns one, so an action writes
5
+ # The ONE door for building an action's reply (issue #182). Component#reply
6
+ # returns one, so an action writes
7
7
  #
8
8
  # reply.replace.flash(:error, msg)
9
9
  #
10
- # instead of
10
+ # There is no constant to qualify (reply is a method, resolved on the
11
+ # component — so a namespaced component needs no `Response = …` alias) and no
12
+ # `self` to thread (the component is bound).
11
13
  #
12
- # Phlex::Reactive::Response.replace(self).flash(:error, msg)
14
+ # Reply is NOT a Response and does NOT subclass one: each verb calls the
15
+ # Response `build_*` class method, supplying the bound component as the
16
+ # subject, and returns the real, frozen Response value the endpoint honors. So
17
+ # immutability, chaining (.flash/.stream/.also/.js/.defer), and render_self?
18
+ # come from the Response value object untouched — the chain is plain Response
19
+ # all the way down.
13
20
  #
14
- # Two warts disappear: there is no constant to qualify (reply is a method,
15
- # resolved on the component so a namespaced component needs no
16
- # `Response = …` alias) and no `self` to thread (the component is bound).
17
- #
18
- # Reply is NOT a Response and does NOT subclass one: each verb forwards to
19
- # the Response class method, supplying the bound component as the subject, and
20
- # returns the real, frozen Response value the endpoint already honors. So
21
- # immutability, chaining (.flash/.stream/.also_update/.also_replace), and
22
- # render_self? are inherited untouched — and there is no "verb after a chained
23
- # Response" asymmetry, because the chain is plain Response all the way down.
24
- #
25
- # Response remains the public value object (it's what the endpoint reads); it
26
- # is simply an internal detail you rarely name directly now.
21
+ # Response remains the value object the endpoint reads; you never construct it
22
+ # directly (its former public class verbs raise a guided rewrite).
27
23
  class Reply
28
- # Distinguishes `reply.remove` (no args — remove the bound component
29
- # itself) from `reply.remove(:items, model)` (remove a collection row).
30
- # A sentinel, not nil, so `reply.remove(:items, nil)` stays unambiguous.
31
- UNSET = Object.new
32
- private_constant :UNSET
33
-
34
24
  def initialize(component)
35
25
  @component = component
36
26
  end
@@ -40,70 +30,86 @@ module Phlex
40
30
  # Re-render in place. `morph: true` morphs the subtree (preserves the
41
31
  # focused input + caret) instead of an outerHTML swap — see #morph.
42
32
  def replace(morph: false)
43
- Response.replace(@component, morph:)
33
+ Response.build_replace(@component, morph:)
44
34
  end
45
35
 
46
36
  # Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
47
37
  def morph
48
- Response.morph(@component)
38
+ Response.build_morph(@component)
49
39
  end
50
40
 
51
41
  # Update only inner HTML (preserves the root element + its token attr).
52
42
  # `morph: true` morphs the inner HTML in place (issue #113) so a
53
43
  # cross-tab/per-field update keeps a focused input's caret.
54
44
  def update(morph: false)
55
- Response.update(@component, morph:)
45
+ Response.build_update(@component, morph:)
56
46
  end
57
47
 
58
48
  # Reactive collections (issue #35) — add/remove a row in a declared
59
49
  # reactive_collection, emitting the row stream + the count companion +
60
50
  # the empty-state toggle as ONE Response. The bound component is the
61
- # container (it carries the declaration + size resolver).
51
+ # container (it carries the declaration + size resolver). The collection is
52
+ # named by the `to:`/`from:` keyword (issue #182 — reads as Ruby, and a
53
+ # `to:` present unambiguously means "a collection row"):
62
54
  #
63
- # def add_item(...) = (item = @list.items.create!(...); reply.append(:items, item))
64
- # def remove_item(id:) = (@list.items.find(id).destroy!; reply.remove(:items, id))
55
+ # def add_item(...) = (item = @list.items.create!(...); reply.append(item, to: :items))
56
+ # def remove_item(id:) = (@list.items.find(id).destroy!; reply.remove(id, from: :items))
65
57
  #
66
58
  # `model` is the row's record; remove also accepts the row's dom-id string.
67
- def append(name, model)
68
- Response.collection_append(@component, name, model)
59
+ # Extra kwargs (issue #186) thread to the row component's new(model:, **row_kwargs)
60
+ # — e.g. reply.append(item, to: :items, autofocus: true) → ItemRow.new(item:,
61
+ # autofocus: true). Additive: a no-kwarg call is byte-identical to before.
62
+ def append(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, **row_kwargs)
63
+ collection_build!(:build_collection_append, :append, model, legacy_model, to, row_kwargs)
69
64
  end
70
65
 
71
- def prepend(name, model)
72
- Response.collection_prepend(@component, name, model)
66
+ def prepend(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, **row_kwargs)
67
+ collection_build!(:build_collection_prepend, :prepend, model, legacy_model, to, row_kwargs)
73
68
  end
74
69
 
75
- # Two forms:
76
- # reply.remove # remove the bound component's own element
77
- # reply.remove(:items, model) # remove a collection row + count + empty
78
- def remove(name = UNSET, model = UNSET)
79
- return Response.remove(@component) if name.equal?(UNSET)
70
+ # Two forms, dispatched on the PRESENCE of `from:` (issue #182 — no sentinel
71
+ # overload): bare `reply.remove` removes the bound component's own element;
72
+ # `reply.remove(model, from: :items)` removes a collection row + count +
73
+ # empty-state. `model` may be the record or the row's dom-id string.
74
+ #
75
+ # reply.remove # remove the bound component's element
76
+ # reply.remove(model, from: :items) # remove a collection row
77
+ def remove(model = UNSET_ARG, legacy_model = UNSET_ARG, from: UNSET_ARG)
78
+ reject_legacy_form!(:remove, model, legacy_model)
79
+ # Bare `reply.remove` (no positional, no from:) removes self.
80
+ return Response.build_remove(@component) if from.equal?(UNSET_ARG) && model.equal?(UNSET_ARG)
80
81
 
81
- Response.collection_remove(@component, name, model)
82
+ if from.equal?(UNSET_ARG)
83
+ raise ArgumentError,
84
+ "reply.remove(model, …) needs a source collection — reply.remove(model, from: :name)"
85
+ end
86
+ reject_symbol_first!(:remove, model, :from, from)
87
+ Response.build_collection_remove(@component, from, resolve_row_arg(model))
82
88
  end
83
89
 
84
90
  # Subject-free builders — pass straight through so they read naturally.
85
91
 
86
92
  # Client-side full navigation (Turbo.visit). Pass a *_url.
87
93
  def redirect(url)
88
- Response.redirect(url)
94
+ Response.build_redirect(url)
89
95
  end
90
96
 
91
97
  # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
92
98
  def with(*strings)
93
- Response.with(*strings)
99
+ Response.build_with(*strings)
94
100
  end
95
101
 
96
102
  # Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
97
103
  # (issue #30) — partial/per-field update, NO full-self replace, so the
98
104
  # component's live inputs survive. The bound component supplies the token.
99
105
  def streams(*strings)
100
- Response.streams(@component, *strings)
106
+ Response.build_streams(@component, *strings)
101
107
  end
102
108
 
103
109
  # Defer an expensive segment (issue #165): `reply.defer(Totals.new(...))`.
104
110
  #
105
111
  # With NO prior verb, the bound component's token is refreshed via a tiny
106
- # token-only stream (Response.streams), NOT a full self-replace: a full
112
+ # token-only stream (reply.streams), NOT a full self-replace: a full
107
113
  # replace would re-render the acting component SYNCHRONOUSLY on the request
108
114
  # thread — and if you defer your OWN subject (`reply.defer(self)`), that is
109
115
  # the very render you meant to move OFF the critical path, so the defer
@@ -115,7 +121,62 @@ module Phlex
115
121
  # reply.streams(volume_cell).defer(SessionTotals.new(workout: @workout))
116
122
  # reply.replace.defer(Totals.new(order: @order)) # explicit self re-render
117
123
  def defer(component, placeholder: nil, morph: false)
118
- Response.streams(@component).defer(component, placeholder:, morph:)
124
+ Response.build_streams(@component).defer(component, placeholder:, morph:)
125
+ end
126
+
127
+ private
128
+
129
+ # Sentinel distinguishing "keyword omitted" from an explicit nil value, so
130
+ # `reply.remove(nil, from: :items)` (remove a row whose id resolves to nil)
131
+ # stays distinct from bare `reply.remove` (remove self). Reused for the
132
+ # positional model too, so the Symbol-first misuse can be caught precisely.
133
+ UNSET_ARG = Object.new
134
+ private_constant :UNSET_ARG
135
+
136
+ # Shared append/prepend build: catch the old two-positional form, require the
137
+ # `to:` keyword, reject a Symbol-first model, then delegate to the Response
138
+ # builder (name first, model second — its internal order).
139
+ def collection_build!(builder, verb, model, legacy_model, to, row_kwargs)
140
+ # Old form `reply.append(:name, model)` passes a SECOND positional — the
141
+ # arity would just error, so intercept it with the guided rewrite.
142
+ reject_legacy_form!(verb, model, legacy_model)
143
+ if to.equal?(UNSET_ARG)
144
+ raise ArgumentError,
145
+ "reply.#{verb} needs a target collection — reply.#{verb}(model, to: :name)"
146
+ end
147
+ reject_symbol_first!(verb, model, :to, to)
148
+ Response.public_send(builder, @component, to, resolve_row_arg(model), **row_kwargs)
149
+ end
150
+
151
+ # The pre-#182 call `reply.<verb>(:name, model)` — a Symbol name first, the
152
+ # model second. Detect it by the presence of the second positional and print
153
+ # the exact keyword rewrite.
154
+ def reject_legacy_form!(verb, model, legacy_model)
155
+ return if legacy_model.equal?(UNSET_ARG)
156
+
157
+ keyword = verb == :remove ? :from : :to
158
+ raise ArgumentError,
159
+ "reply.#{verb}(#{model.inspect}, …) — the collection name moved to a keyword " \
160
+ "(issue #182): reply.#{verb}(model, #{keyword}: #{model.inspect})"
161
+ end
162
+
163
+ # Issue #182 removed the Symbol-first form (`reply.append(:items, model)`).
164
+ # A Symbol in the model position is almost certainly that old call shape, so
165
+ # raise the exact keyword rewrite instead of trying to build a row from a
166
+ # Symbol "record".
167
+ def reject_symbol_first!(verb, model, keyword, name)
168
+ return unless model.is_a?(Symbol)
169
+
170
+ raise ArgumentError,
171
+ "reply.#{verb}(#{model.inspect}, …) — the collection name moved to a keyword " \
172
+ "(issue #182): reply.#{verb}(model, #{keyword}: #{name.inspect})"
173
+ end
174
+
175
+ # A row build needs a real model/dom-id — never the omitted sentinel.
176
+ def resolve_row_arg(model)
177
+ raise ArgumentError, "reply collection verbs need a model or dom-id as the first argument" if model.equal?(UNSET_ARG)
178
+
179
+ model
119
180
  end
120
181
  end
121
182
  end