phlex-reactive 0.13.1 → 0.13.3

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.
@@ -389,6 +389,13 @@ module Phlex
389
389
  # inputs: { title: :string, qty: :number },
390
390
  # outputs: %i[title_preview char_count]
391
391
  #
392
+ # A CHECKBOX contributes its checked state, never its constant .value
393
+ # (issue #262): 1/0 as a :number, "true"/"false" as a :string, and a
394
+ # real boolean as a :boolean. It wins over the hidden companion Rails
395
+ # renders before it. A radio group contributes its checked radio's value.
396
+ #
397
+ # reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
398
+ #
392
399
  # An output with no matching form field writes to its reactive_text(:name)
393
400
  # node (textContent); a declared input also mirrors into its own text node
394
401
  # with no reducer. The array form's wire stays byte-identical.
@@ -449,7 +449,7 @@ module Phlex
449
449
  # the baseline is the DOM's own `defaultValue`/`defaultChecked`/
450
450
  # `defaultSelected` from the last server render (dirty = current ≠ default).
451
451
  # The descriptor deep-merges via mix, so a caller's own data-action is
452
- # token-joined, not clobbered (CLAUDE.md Never-Do #8).
452
+ # token-joined, not clobbered (AGENTS.md Never-Do #8).
453
453
  def reactive_field(param, **attrs)
454
454
  # Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
455
455
  # print the reactive_dirty rewrite.
@@ -1232,8 +1232,9 @@ module Phlex
1232
1232
  # The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
1233
1233
  # names, byte-identical to the shipped wire so the client keeps its numeric
1234
1234
  # coercion. Typed (hash form) → a JSON OBJECT of name→type
1235
- # ({"title":"string","qty":"number"}) so the client reads a :string raw and
1236
- # coerces a :number through Number.
1235
+ # ({"title":"string","qty":"number"}) so the client reads a :string raw,
1236
+ # coerces a :number through Number and reads a :boolean as a checkbox's
1237
+ # checked state (issue #262).
1237
1238
  def compute_inputs_param(definition)
1238
1239
  types = definition.input_types
1239
1240
  return definition.inputs.map(&:to_s).to_json if types.nil?
@@ -102,7 +102,8 @@ module Phlex
102
102
  # `input_types` (issue #104) is nil for the ARRAY input form (untyped ⇒ the
103
103
  # client coerces every input through Number, the shipped behavior) and a
104
104
  # { name => type } hash for the typed HASH form (:string reads the field
105
- # value raw, :number coerces). `inputs` stays the ordered name list either
105
+ # value raw, :number coerces, :boolean reads a checkbox's checked state —
106
+ # issue #262). `inputs` stays the ordered name list either
106
107
  # way, so iteration order is preserved and the array-form wire is unchanged.
107
108
  #
108
109
  # `mirror` (issue #159) is nil when undeclared, else a { name => [id
@@ -174,6 +174,22 @@ module Phlex
174
174
  coerced.equal?(DROP) ? {} : coerced
175
175
  end
176
176
 
177
+ # The key this DECLARATION holds for `name` if it declares it as an ARRAY
178
+ # param, else nil. Reads the declaration, never the incoming params, so the
179
+ # endpoint's empty-group announcement (issue #258) can only ever fill
180
+ # something the action asked for.
181
+ #
182
+ # A key that is neither a String nor a Symbol stays unresolved on purpose:
183
+ # `compile` accepts `{ 0 => [:string] }`, but `coerce_hash` raises on
184
+ # `0.to_sym` the moment that key is PRESENT, and the announcement is what
185
+ # would make it present. Resolving it would turn a request that answers 200
186
+ # and fills nothing into a 500.
187
+ def array_param(name)
188
+ name = name.to_s
189
+ key, type = @schema.find { |k, _| (k.is_a?(String) || k.is_a?(Symbol)) && k.to_s == name }
190
+ key if type.is_a?(Array)
191
+ end
192
+
177
193
  private
178
194
 
179
195
  # Coerce a value against a declared type. Arrays accept both a real JSON
@@ -27,15 +27,19 @@ module Phlex
27
27
  # ## The handle rides ActiveJob metadata
28
28
  #
29
29
  # `reply.pending` installs a Pending::Handle in a thread-local and runs the
30
- # caller's enqueue inside it; #serialize below copies it into the job's
31
- # metadata, #deserialize restores it. So `perform`'s ARITY IS UNTOUCHED and
32
- # every OTHER caller of the same job — a nightly sweep, a webhook — enqueues
33
- # it with no handle, in which case `reactive_settle` is a NO-OP that returns
34
- # nil. That is load-bearing: these jobs almost always have non-UI callers.
30
+ # caller's enqueue inside it; #initialize below captures it onto the job
31
+ # instance, #serialize copies it into the job's metadata, #deserialize
32
+ # restores it. So `perform`'s ARITY IS UNTOUCHED and every OTHER caller of
33
+ # the same job — a nightly sweep, a webhook — builds it with no handle, in
34
+ # which case `reactive_settle` is a NO-OP that returns nil. That is
35
+ # load-bearing: these jobs almost always have non-UI callers.
35
36
  #
36
- # (`perform_now` does not round-trip through serialize/deserialize, so it
37
- # carries no handle either — which is the correct reading: a synchronous
38
- # call has no pending UI waiting on it.)
37
+ # (A `perform_now` INSIDE the enqueue block does carry the handle, since the
38
+ # instance is built there — so it settles synchronously. That is the right
39
+ # reading: `reply.pending` already marked the targets, and something has to
40
+ # resolve the shimmer. The settle's durable message is replayed from
41
+ # since-id 0 when the client opens the subscription, so arriving before it
42
+ # exists is safe.)
39
43
  #
40
44
  # ## A rolled-back action
41
45
  #
@@ -57,12 +61,45 @@ module Phlex
57
61
  # shared namespace with every other gem in the app.
58
62
  SETTLE_METADATA_KEY = "phlex_reactive_settle"
59
63
 
60
- # Capture the in-flight settle handle at ENQUEUE time. This is the same
61
- # seam pgbus's own ActiveJob::CurrentAttributes integration uses, and it
62
- # is adapter-agnostic: it works under :async, :test and :inline as well as
63
- # a real backend, which is what app specs need.
64
+ # Capture the in-flight settle handle at INSTANTIATION (issue #254).
65
+ #
66
+ # `serialize` is the seam pgbus's own ActiveJob::CurrentAttributes
67
+ # integration uses, and it looked like the enqueue-time hook — but under
68
+ # Rails' `enqueue_after_transaction_commit = true` (the 7.2+ recommended
69
+ # setting) `job.enqueue` is deferred to
70
+ # ActiveRecord.after_all_transactions_commit, and the endpoint runs every
71
+ # action inside a transaction. So the deferral — and with it `serialize` —
72
+ # always fires AFTER reply.pending's `with_handle` block has exited, with
73
+ # an empty thread-local: no metadata key, a no-op `reactive_settle`, and a
74
+ # row left shimmering until someone reloads.
75
+ #
76
+ # `new` is the one moment guaranteed to be inside the block:
77
+ # `perform_later` → `job_or_instantiate` → `new` is synchronous, deferral
78
+ # or not. Pending#each_with_narrowed_handle narrows the thread-local per
79
+ # record BEFORE `perform_later`, so the `job:`/`args:` attribution
80
+ # contract is preserved.
81
+ def initialize(...)
82
+ super
83
+ @reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
84
+ end
85
+
86
+ # The same capture at ENQUEUE, for an instance built BEFORE the block and
87
+ # enqueued inside it (`job = MyJob.new(...)` … `reply.pending { job.enqueue }`).
88
+ # `enqueue` runs synchronously inside the block — it is the deferral it
89
+ # REGISTERS that runs later — so this is the last moment the thread-local
90
+ # is visible. `||=` never overwrites: a retry's `retry_job` re-enqueues a
91
+ # DESERIALIZED instance, which must keep the handle it came back with.
92
+ def enqueue(...)
93
+ @reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
94
+ super
95
+ end
96
+
97
+ # Prefer the captured handle; the thread-local fallback covers an instance
98
+ # serialized inside the block without going through either hook. A retry
99
+ # re-enqueue re-serializes the SAME instance, which keeps its handle — the
100
+ # right reading: the pending UI is still waiting on this work.
64
101
  def serialize
65
- handle = Phlex::Reactive::Pending.current_handle
102
+ handle = @reactive_settle_handle || Phlex::Reactive::Pending.current_handle
66
103
  return super unless handle
67
104
 
68
105
  super.merge(SETTLE_METADATA_KEY => handle.to_h_wire)
@@ -64,10 +64,17 @@ module Phlex
64
64
  # `:file` param is present, issue #34): token + act flat, params bracketed.
65
65
  # No JSON Content-Type — Rails' test `post` builds the multipart body from
66
66
  # the nested Hash (files ride as UploadedFile values).
67
- def post_reactive_multipart(component_or_class, act, params: {}, payload: {})
67
+ #
68
+ # `empty_groups:` names the `[]` groups the client cleared (issue #258). A
69
+ # form body cannot carry an empty array, so the client leaves those keys
70
+ # out and announces them in a field of its own; passing the names here is
71
+ # how a spec reproduces that request. Omit it and the body is exactly what
72
+ # it was before the field existed.
73
+ def post_reactive_multipart(component_or_class, act, params: {}, payload: {}, empty_groups: [])
68
74
  token = reactive_action_token(component_or_class, payload)
69
- post Phlex::Reactive.action_path,
70
- params: { token:, act:, params: },
75
+ body = { token:, act:, params: }
76
+ body[:empty_groups] = empty_groups if empty_groups.present?
77
+ post Phlex::Reactive.action_path, params: body,
71
78
  headers: { "Accept" => "text/vnd.turbo-stream.html" }
72
79
  end
73
80
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.13.1"
5
+ VERSION = "0.13.3"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson