phlex-reactive 0.2.9 → 0.4.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.
@@ -78,6 +78,19 @@ module Phlex
78
78
  return [redirect_stream(result.redirect_url)] if result.redirect?
79
79
 
80
80
  streams = result.streams
81
+
82
+ # Partial update (Response.streams / reply.streams, issue #30): the action
83
+ # re-rendered only PART of the component and opted out of the full-self
84
+ # replace. Append a tiny token-only stream so the signed token still rolls
85
+ # forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
86
+ # only if the caller already supplied THIS component's token (idempotent) —
87
+ # the dedupe is scoped to the actor's own target, NOT a global substring,
88
+ # so a partial reply that legitimately includes another reactive
89
+ # component's stream (which carries its OWN token) still refreshes ours.
90
+ if result.refresh_token? && !carries_token_for?(streams, result.token_component)
91
+ return [*streams, result.token_component.to_stream_token]
92
+ end
93
+
81
94
  # Guarantee the component's signed identity token is refreshed unless the
82
95
  # Response opted out (remove/redirect navigate away — handled above). The
83
96
  # client reads the next token from the response body (#extractToken), so
@@ -88,12 +101,23 @@ module Phlex
88
101
  # replace when a hand-built `with(...)` stream omits it. Idempotent: a
89
102
  # Response.replace(self)/update(self) already carries the token, so we
90
103
  # don't double the self-render.
91
- if result.render_self? && streams.none? { |s| s.include?("data-reactive-token-value") }
104
+ if result.render_self? && streams.none? { it.include?("data-reactive-token-value") }
92
105
  streams = [component.to_stream_replace, *streams]
93
106
  end
94
107
  streams
95
108
  end
96
109
 
110
+ # True when one of `streams` already carries a fresh token TARGETING this
111
+ # component — i.e. the caller hand-built the actor's own token-bearing
112
+ # stream, so appending to_stream_token would double it. Scoped to the
113
+ # component's target id (not a global substring) so a sibling component's
114
+ # stream, which carries its OWN token for a DIFFERENT target, doesn't fool
115
+ # us into skipping this component's refresh.
116
+ def carries_token_for?(streams, component)
117
+ target = %(target="#{ERB::Util.html_escape(component.id)}")
118
+ streams.any? { it.include?("data-reactive-token-value") && it.include?(target) }
119
+ end
120
+
97
121
  # A 200 turbo-stream carrying a namespaced custom action the client turns
98
122
  # into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
99
123
  # (response.redirected). The matching client handler is registered in
@@ -102,9 +126,9 @@ module Phlex
102
126
  %(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
103
127
  end
104
128
 
105
- def transaction_wrapper(&block)
129
+ def transaction_wrapper(&)
106
130
  if defined?(::ActiveRecord::Base)
107
- ::ActiveRecord::Base.transaction(&block)
131
+ ::ActiveRecord::Base.transaction(&)
108
132
  else
109
133
  yield
110
134
  end
@@ -139,29 +163,60 @@ module Phlex
139
163
 
140
164
  # Coerce a value against a declared type. A type is one of:
141
165
  # * a scalar symbol (:string/:integer/:float/:boolean)
166
+ # * :file — a multipart upload (issue #34)
142
167
  # * a Hash schema ({ id: :integer, ... }) — nested object
143
168
  # * a one-element Array ([:integer] / [{ ... }]) — array of that
144
169
  # Arrays accept both a real JSON array and a Rails-style index hash
145
170
  # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
146
171
  def coerce(value, type)
147
- if type.is_a?(Array)
172
+ case type
173
+ when Array
148
174
  coerce_array(value, type.first)
149
- elsif type.is_a?(Hash)
175
+ when Hash
150
176
  coerce_hash(value, type)
177
+ when :file
178
+ coerce_file(value)
151
179
  else
152
180
  coerce_scalar(value, type)
153
181
  end
154
182
  end
155
183
 
184
+ # An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
185
+ # which would corrupt it into a string the action can't attach. Anything
186
+ # that isn't an uploaded file (a forged/malformed scalar, an empty input)
187
+ # returns DROP, so the method's keyword default applies — consistent with
188
+ # the #16 rule that a value that can't be coerced to its type is dropped,
189
+ # not fabricated. Duck-types on UploadedFile's interface (original_filename
190
+ # + a readable IO) rather than naming a class, so a Rack::Test upload, an
191
+ # ActionDispatch upload, and a Falcon multipart body all qualify.
192
+ def coerce_file(value)
193
+ uploaded_file?(value) ? value : DROP
194
+ end
195
+
196
+ def uploaded_file?(value)
197
+ value.respond_to?(:original_filename) && value.respond_to?(:read)
198
+ end
199
+
156
200
  # A real array (or Rails index hash) coerces element-wise. A malformed
157
201
  # present-but-non-array value returns DROP rather than [] — coercing a stray
158
202
  # scalar to an empty array would let a bad payload read as an explicit
159
203
  # "clear everything" on update!(declared_array:).
204
+ #
205
+ # An ELEMENT that coerces to DROP (e.g. a non-file in a [:file] array, a
206
+ # forged/mixed payload) is rejected from the result — the same rule
207
+ # coerce_hash applies to a dropped value, so the internal DROP sentinel
208
+ # never leaks into the action. A genuinely empty input array stays [] (an
209
+ # explicit empty collection), but an array whose every element drops
210
+ # returns DROP, so the keyword default applies rather than handing the
211
+ # action a surprise [].
160
212
  def coerce_array(value, element_type)
161
213
  values = array_values(value)
162
214
  return DROP if values.nil?
215
+ return [] if values.empty?
163
216
 
164
- values.map { |element| coerce(element, element_type) }
217
+ coerced = values.map { coerce(it, element_type) }
218
+ coerced.reject! { it.equal?(DROP) }
219
+ coerced.empty? ? DROP : coerced
165
220
  end
166
221
 
167
222
  # Keep declared keys only (drop undeclared — no mass assignment), recursing
@@ -195,9 +250,9 @@ module Phlex
195
250
  def array_values(value)
196
251
  return value.to_a if value.is_a?(Array)
197
252
 
198
- if value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
199
- to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
200
- end
253
+ return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
254
+
255
+ to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
201
256
  end
202
257
 
203
258
  # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
@@ -234,13 +289,19 @@ module Phlex
234
289
  end
235
290
  end
236
291
 
292
+ # Matches each bracket segment in "items_attributes][0][qty]" — the part
293
+ # after the first "[". Hoisted to a frozen constant so coercing a bracketed
294
+ # key doesn't recompile the pattern per key on every request.
295
+ BRACKET_SEGMENT = /[^\[\]]+/
296
+ private_constant :BRACKET_SEGMENT
297
+
237
298
  # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
238
299
  # "0", "qty"]. A key with no brackets is a single-element path.
239
300
  def bracket_path(key)
240
301
  return [key] unless key.include?("[")
241
302
 
242
303
  head, rest = key.split("[", 2)
243
- [head, *rest.scan(/[^\[\]]+/)]
304
+ [head, *rest.scan(BRACKET_SEGMENT)]
244
305
  end
245
306
 
246
307
  # Walk/create nested hashes along `path`, then merge `value` at the leaf so
@@ -270,7 +331,7 @@ module Phlex
270
331
  # already gates this; defense in depth against constant injection.
271
332
  def resolve_component(name)
272
333
  klass = name.to_s.safe_constantize
273
- unless klass&.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
334
+ unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
274
335
  raise Phlex::Reactive::InvalidToken
275
336
  end
276
337
 
@@ -4,12 +4,16 @@ import { Controller } from "@hotwired/stimulus"
4
4
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
5
5
  // for interactive components. A component declares its actions in Ruby (via
6
6
  // Phlex::Reactive::Component); this controller binds DOM events to a single
7
- // HTTP round trip and lets Turbo morph the re-rendered component back in.
7
+ // HTTP round trip and lets Turbo apply the re-rendered component back in
8
+ // (replace by default; method="morph" — Response.morph — preserves focus).
8
9
  //
9
10
  // Wire format (client -> server), POST <action path>, turbo-stream Accept:
10
- // { token: "<signed identity>", act: "<action>", params: {...} }
11
+ // { token: "<signed identity>", act: "<action>", params: {...} } (JSON)
11
12
  // (`act`, not `action`: `action` is a reserved Rails routing param.)
12
13
  // The token is a MessageVerifier-signed { component, gid } — NO state is sent.
14
+ // When the root holds a chosen <input type="file">, the SAME payload is sent as
15
+ // multipart FormData instead (token/act flat, params bracketed, files appended)
16
+ // so an upload reaches the action (issue #34) — only the encoding differs.
13
17
  // The response is a <turbo-stream> that replaces the component by its id.
14
18
  //
15
19
  // Server -> client live updates use the SAME element id, pushed over the
@@ -31,9 +35,38 @@ export function registerReactiveVisit() {
31
35
  if (url) window.Turbo.visit(url, { action: "advance" })
32
36
  }
33
37
  }
38
+
39
+ // Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
40
+ // update (Response.streams / reply.streams) re-renders only PART of a component
41
+ // — so there's no full-self replace to carry the next signed token. The server
42
+ // instead emits `<turbo-stream action="reactive:token" target="<id>"
43
+ // data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
44
+ // the token out of the response body for the NEXT queued request; this handler
45
+ // keeps the DOM in sync too, writing the attribute onto the root element so the
46
+ // `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
47
+ // replaced — so a focused <input> + caret survive (the whole point: update a
48
+ // total cell without tearing down the field the user is typing in).
49
+ export function registerReactiveToken() {
50
+ const actions = window.Turbo?.StreamActions
51
+ if (!actions || actions["reactive:token"]) return
52
+ actions["reactive:token"] = function () {
53
+ const token = this.getAttribute("data-reactive-token-value")
54
+ const target = this.getAttribute("target")
55
+ if (!token || !target) return
56
+ const el = document.getElementById(target)
57
+ // Stimulus reads the token via the `token` value -> data-reactive-token-value.
58
+ if (el) el.setAttribute("data-reactive-token-value", token)
59
+ }
60
+ }
61
+
62
+ export function registerReactiveActions() {
63
+ registerReactiveVisit()
64
+ registerReactiveToken()
65
+ }
66
+
34
67
  if (typeof window !== "undefined") {
35
- if (window.Turbo) registerReactiveVisit()
36
- else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
68
+ if (window.Turbo) registerReactiveActions()
69
+ else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
37
70
  }
38
71
 
39
72
  // --- Registration guard (issue #26 part 2) -------------------------------
@@ -89,6 +122,7 @@ export default class extends Controller {
89
122
 
90
123
  #tokenCache // freshest token, threaded synchronously across queued requests
91
124
  #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
125
+ #actionPathCache // page-stable action path, resolved once per controller
92
126
 
93
127
  // Mark that a reactive controller actually connected, so the registration
94
128
  // guard above knows the controller was registered (issue #26 part 2).
@@ -172,24 +206,34 @@ export default class extends Controller {
172
206
 
173
207
  async #perform(action, params) {
174
208
  // Auto-collect named field values inside this component so a button-
175
- // triggered action still receives sibling inputs (Livewire-style).
176
- // Explicit params (data-reactive-params-param) win over collected fields.
177
- const fieldParams = this.#collectFields()
178
-
179
- const body = JSON.stringify({
180
- token: this.#currentToken,
181
- act: action,
182
- params: { ...fieldParams, ...this.#parseParams(params) },
183
- })
209
+ // triggered action still receives sibling inputs (Livewire-style), plus any
210
+ // chosen file inputs in the SAME walk. Explicit params
211
+ // (data-reactive-params-param) win over collected fields.
212
+ const { fields, files } = this.#collectFields()
213
+ const allParams = { ...fields, ...this.#parseParams(params) }
214
+ const token = this.#currentToken
215
+
216
+ // File/multipart path (issue #34): if THIS root has a populated
217
+ // <input type="file">, the action can't be JSON (JSON.stringify drops the
218
+ // File). Send FormData instead — token + act + scalar params as fields, each
219
+ // chosen file appended. The morph/token machinery downstream is identical;
220
+ // only the request ENCODING differs when files are present.
221
+ const multipart = files.length > 0
222
+ const body = multipart
223
+ ? this.#buildFormData(token, action, allParams, files)
224
+ : JSON.stringify({ token, act: action, params: allParams })
184
225
 
185
226
  this.element.setAttribute("aria-busy", "true")
186
227
 
187
228
  try {
188
229
  const headers = {
189
- "Content-Type": "application/json",
190
230
  Accept: "text/vnd.turbo-stream.html",
191
231
  "X-CSRF-Token": this.#csrfToken(),
192
232
  }
233
+ // For JSON we declare the content type; for multipart we must NOT — the
234
+ // browser sets `multipart/form-data; boundary=…` itself, and overriding it
235
+ // would strip the boundary and corrupt the body server-side.
236
+ if (!multipart) headers["Content-Type"] = "application/json"
193
237
  // Send the pgbus SSE connection id (if subscribed) so the server can
194
238
  // exclude this connection from its own broadcast echo — the actor
195
239
  // already gets the action's HTTP response. Harmless without pgbus.
@@ -222,8 +266,10 @@ export default class extends Controller {
222
266
  // Capture the new token from the response synchronously, so the next
223
267
  // queued request uses it without waiting for the async DOM morph.
224
268
  this.#currentToken = this.#extractToken(html) ?? this.#currentToken
225
- // Turbo applies the <turbo-stream> ops (replace/morph by id), preserving
226
- // focus/scroll/listeners on unchanged nodes.
269
+ // Turbo applies the <turbo-stream> ops by id. A plain replace is an
270
+ // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
271
+ // replace (Response.morph) or an update morphs in place, preserving the
272
+ // focused input + caret on unchanged nodes — see issue #28.
227
273
  window.Turbo.renderStreamMessage(html)
228
274
  } catch (error) {
229
275
  console.error("[phlex-reactive] action error", error)
@@ -255,12 +301,22 @@ export default class extends Controller {
255
301
  return el.closest('[data-controller~="reactive"]') === this.element
256
302
  }
257
303
 
304
+ // One walk over THIS root's named controls (not a nested reactive root's),
305
+ // returning both the scalar `fields` and any chosen `files`. A file input's
306
+ // `.value` is the useless "C:\fakepath\…" string, never a scalar — so its
307
+ // chosen files are collected separately (honoring `multiple`) and it adds no
308
+ // phantom blank value (issue #34). An empty `files` keeps the JSON path.
258
309
  #collectFields() {
259
310
  const fields = {}
260
- // Standard form controls owned by THIS root (not a nested reactive root).
311
+ const files = []
261
312
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
262
313
  if (!this.#ownsField(field)) return
263
- if (field.type === "checkbox") {
314
+ if (field.type === "file") {
315
+ // Carry the input's `multiple` flag so #buildFormData keeps the array
316
+ // shape (params[name][]) even when the user picked exactly one file —
317
+ // otherwise a [:file] schema would see a lone scalar upload and drop it.
318
+ for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
319
+ } else if (field.type === "checkbox") {
264
320
  fields[field.name] = field.checked
265
321
  } else if (field.type === "radio") {
266
322
  if (field.checked) fields[field.name] = field.value
@@ -288,7 +344,71 @@ export default class extends Controller {
288
344
  fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
289
345
  }
290
346
  })
291
- return fields
347
+ return { fields, files }
348
+ }
349
+
350
+ // Build the multipart body (issue #34). `token`/`act` are flat fields the
351
+ // endpoint reads from params[:token]/params[:act]; scalar params nest under
352
+ // params[<key>] (Rails parses the bracket into params[:params]); each file is
353
+ // appended under params[<name>] (single) — a second file with the same name
354
+ // (a `multiple` picker, several inputs sharing a name) is sent as
355
+ // params[<name>][] so Rails coerces it to an array for a [:file] schema.
356
+ #buildFormData(token, action, params, files) {
357
+ const fd = new FormData()
358
+ fd.append("token", token)
359
+ fd.append("act", action)
360
+ for (const [key, value] of Object.entries(params)) {
361
+ this.#appendField(fd, `params[${key}]`, value)
362
+ }
363
+ const multiNames = this.#multiFileNames(files)
364
+ for (const { name, file, multiple } of files) {
365
+ // params[name][] when the input is `multiple` (array shape even for one
366
+ // file) OR the name repeats across inputs; otherwise a lone scalar file.
367
+ const asArray = multiple || multiNames.has(name)
368
+ const key = asArray ? `params[${name}][]` : `params[${name}]`
369
+ fd.append(key, file, file.name)
370
+ }
371
+ return fd
372
+ }
373
+
374
+ // Append a param leaf to FormData under its bracketed key. FormData carries
375
+ // only strings, so a NON-scalar param (a nested object or an array) is
376
+ // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
377
+ // the SAME Rails-form shape the server's expand_bracket_keys / array_values
378
+ // already parse, so a JSON body and a multipart body coerce identically
379
+ // (issue #39). Previously a non-scalar was JSON.stringify'd into one
380
+ // params[key]='<json>' field, which the server received as an un-decodable
381
+ // String leaf and DROPPED (nested hash -> {}, array -> key removed).
382
+ //
383
+ // Arrays use NUMERIC indices (params[key][0], params[key][1]) — required for
384
+ // an array-of-hash so each element's sub-keys stay grouped and the server's
385
+ // index-hash sort rebuilds order; params[key][] would collapse them. A scalar
386
+ // (string/number/boolean) is one string field, mirroring the JSON wire shape
387
+ // (the server's :boolean/:integer casts read "true"/"42"). null/undefined is
388
+ // an empty field. An EMPTY array/object emits NOTHING — FormData can't carry
389
+ // []/{}, so the key is omitted and the action's keyword default applies (this
390
+ // differs from the JSON path, where an explicit [] coerces to an empty array).
391
+ #appendField(fd, key, value) {
392
+ if (value == null) {
393
+ fd.append(key, "")
394
+ } else if (Array.isArray(value)) {
395
+ value.forEach((element, index) => this.#appendField(fd, `${key}[${index}]`, element))
396
+ } else if (typeof value === "object") {
397
+ for (const [subKey, subValue] of Object.entries(value)) {
398
+ this.#appendField(fd, `${key}[${subKey}]`, subValue)
399
+ }
400
+ } else {
401
+ fd.append(key, String(value))
402
+ }
403
+ }
404
+
405
+ // Names that appear more than once across the chosen files (a `multiple`
406
+ // picker, or several file inputs sharing a name) — those go to params[name][]
407
+ // so the server sees an array; a lone file stays params[name].
408
+ #multiFileNames(files) {
409
+ const counts = new Map()
410
+ for (const { name } of files) counts.set(name, (counts.get(name) ?? 0) + 1)
411
+ return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name))
292
412
  }
293
413
 
294
414
  #parseParams(raw) {
@@ -300,13 +420,21 @@ export default class extends Controller {
300
420
  }
301
421
  }
302
422
 
423
+ // The action path comes from a <meta> tag that is fixed for the page's life,
424
+ // so resolve it once per controller and cache it — avoids a querySelector on
425
+ // every dispatch (this runs on the request hot path, once per click/keystroke
426
+ // round trip). Cached on the instance, so a fresh connect() (after a Turbo
427
+ // navigation swaps the element) re-reads it.
303
428
  #actionPath() {
304
- return (
429
+ return (this.#actionPathCache ??=
305
430
  document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
306
- "/reactive/actions"
307
- )
431
+ "/reactive/actions")
308
432
  }
309
433
 
434
+ // CSRF token and connection id are read LIVE (not cached) on purpose: Rails
435
+ // can rotate the CSRF token, and the pgbus connection id changes on an SSE
436
+ // reconnect — caching either would send a stale value. A single querySelector
437
+ // per request is cheap next to the round trip itself.
310
438
  #csrfToken() {
311
439
  return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
312
440
  }
@@ -29,8 +29,8 @@ module Phlex
29
29
  index = stimulus_index_path
30
30
 
31
31
  unless index
32
- say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n" \
33
- " #{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
32
+ say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n " \
33
+ "#{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
34
34
  return
35
35
  end
36
36
 
@@ -44,9 +44,9 @@ module Phlex
44
44
  end
45
45
 
46
46
  # Fallback: if the standard import line wasn't found, append both lines.
47
- unless File.read(index).include?(REGISTER_LINE)
48
- append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
49
- end
47
+ return if File.read(index).include?(REGISTER_LINE)
48
+
49
+ append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
50
50
  end
51
51
 
52
52
  def show_post_install
@@ -69,7 +69,7 @@ module Phlex
69
69
  %w[
70
70
  app/javascript/controllers/index.js
71
71
  app/javascript/controllers/application.js
72
- ].map { |p| File.join(destination_root, p) }.find { |p| File.exist?(p) }
72
+ ].map { File.join(destination_root, it) }.find { File.exist?(it) }
73
73
  end
74
74
 
75
75
  def relative(path)