phlex-reactive 0.15.1 → 0.15.2

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The fields a morph held over a changed server value (issue #370).
6
+ #
7
+ # Since #338 a morph keeps the value of the field being typed in and writes
8
+ # the render's value as the field's default. When that render differs from
9
+ # the old default, someone else changed the field while the user typed.
10
+ # The client marks such a field and the root's next action request names it
11
+ # in the X-Reactive-Held header (a JSON array of field names). Inside the
12
+ # action, `reactive_held_fields` returns those names, so the app can refuse
13
+ # or merge instead of silently overwriting the other change:
14
+ #
15
+ # def save(title: nil)
16
+ # authorize!(@note, to: :update?)
17
+ # return conflict! if reactive_held_fields.include?("title") && title != @note.title
18
+ # ...
19
+ #
20
+ # The header is UNTRUSTED input, like every request field: it carries names
21
+ # only, never values, and is read as data — capped in bytes, count and
22
+ # length, non-strings dropped, and filtered to the names the running action
23
+ # declares as params (matched on the base name, so `title[en]` counts for a
24
+ # declared `title`). A name the action can't receive can't be overwritten
25
+ # by it. Malformed input reads as nothing held; it never fails a request.
26
+ # It is a hint about what the user saw, not authorization.
27
+ module HeldFields
28
+ HEADER = "X-Reactive-Held"
29
+
30
+ # A raw header longer than this is ignored unparsed.
31
+ MAX_BYTES = 4_096
32
+ # At most this many names are read …
33
+ MAX_NAMES = 64
34
+ # … each at most this long.
35
+ MAX_NAME_LENGTH = 128
36
+
37
+ EMPTY = [].freeze
38
+
39
+ class << self
40
+ # The held field names during the current action ([] outside one).
41
+ def current
42
+ Thread.current[:phlex_reactive_held_fields] || EMPTY
43
+ end
44
+
45
+ # Expose `names` for the block, restoring the previous list after.
46
+ def with(names)
47
+ previous = Thread.current[:phlex_reactive_held_fields]
48
+ Thread.current[:phlex_reactive_held_fields] = names
49
+ yield
50
+ ensure
51
+ Thread.current[:phlex_reactive_held_fields] = previous
52
+ end
53
+
54
+ # The header's names that `action_def` declares, frozen; [] for
55
+ # anything absent, oversized or malformed.
56
+ def parse(raw, action_def)
57
+ return EMPTY if raw.nil? || raw.empty? || raw.bytesize > MAX_BYTES
58
+
59
+ names = JSON.parse(raw)
60
+ names.is_a?(Array) ? filter(names, action_def) : EMPTY
61
+ rescue JSON::ParserError
62
+ EMPTY
63
+ end
64
+
65
+ # Keep the strings among `names` that `action_def` declares as params.
66
+ def filter(names, action_def)
67
+ declared = declared_names(action_def)
68
+ return EMPTY if declared.empty?
69
+
70
+ names.first(MAX_NAMES).select do
71
+ it.is_a?(String) && it.length <= MAX_NAME_LENGTH && declared.include?(base_name(it))
72
+ end.uniq.freeze
73
+ end
74
+
75
+ private
76
+
77
+ def declared_names(action_def)
78
+ params = action_def.params
79
+ params.is_a?(Hash) ? params.keys.map(&:to_s) : EMPTY
80
+ end
81
+
82
+ # "title" for "title", "title[]" and "title[en]".
83
+ def base_name(name) = name.split("[", 2).first
84
+ end
85
+ end
86
+ end
87
+ end
@@ -11,10 +11,15 @@ module Phlex
11
11
  # # -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'
12
12
  #
13
13
  # These are declarative DOM OPERATIONS, not state: nothing is shipped back
14
- # to the server, nothing is trusted from the client, and any server
14
+ # to the server, nothing is trusted from the client, and a server
15
15
  # re-render of the component resets whatever they toggled (the LiveView
16
- # JS-commands caveat — by design; use a signed action for state that must
17
- # survive re-renders).
16
+ # JS-commands caveat; use a signed action for state the server must know).
17
+ # The exception is `keep: true` (issue #369) on the class, visibility and
18
+ # attribute ops: the client records what the op did to each target, and a
19
+ # MORPH of that element re-applies it, so a selection or an expanded row
20
+ # survives another viewer's broadcast. An element a stream REPLACES is a
21
+ # new element and starts clean. Kept state belongs to one tab, so
22
+ # broadcast_to(js:) refuses the flag.
18
23
  #
19
24
  # Targets: a CSS selector string is resolved WITHIN the component's root by
20
25
  # default (nested reactive roots excluded, issue #15 semantics); `:root`
@@ -38,10 +43,13 @@ module Phlex
38
43
  # * the URL set — href/src/srcdoc/action/formaction/xlink:href can carry
39
44
  # a `javascript:` payload; setting them from client ops
40
45
  # is a navigation/injection surface, not a UI toggle.
46
+ # srcset (issue #373) is a URL list that picks what an
47
+ # <img> loads: copy_media copies it from a server-rendered
48
+ # image instead.
41
49
  # * style — inline CSS injection; use classes (add_class/...).
42
50
  # The INTENDED surface is class ops plus boolean/state attributes:
43
51
  # hidden, disabled, open, selected, aria-*, data-*.
44
- URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
52
+ URL_BEARING_ATTRS = %w[href src srcset srcdoc action formaction xlink:href].freeze
45
53
  EVENT_HANDLER_ATTR = /\Aon/i
46
54
 
47
55
  # The attr ops whose args carry a "name" the allowlist must gate. Used to
@@ -118,31 +126,39 @@ module Phlex
118
126
  # selector, document-wide under global: true). Pair it with ONE disclosure
119
127
  # target — with several, the last one wins.
120
128
  # js.toggle("#menu", expanded: "#menu-trigger")
129
+ #
130
+ # `keep:` (issue #369) — on show/hide/toggle, the class ops and the attr
131
+ # ops: a morph of the target re-applies the op's result (the class on or
132
+ # off, `hidden`, the attribute's value or its absence) instead of the
133
+ # server render's. The latest kept op on an element and a class or
134
+ # attribute wins, so "clear all, then select one" works:
135
+ # js.remove_class(".item", "selected", keep: true).add_class("#item-2", "selected", keep: true)
136
+ # With `expanded:`, the trigger's aria-expanded is kept too.
121
137
 
122
- def show(to, global: false, transition: nil, expanded: nil)
123
- append("show", target_args(to, global:, transition:, expanded:))
138
+ def show(to, global: false, transition: nil, expanded: nil, keep: false)
139
+ append("show", target_args(to, global:, transition:, expanded:, keep:))
124
140
  end
125
141
 
126
- def hide(to, global: false, transition: nil, expanded: nil)
127
- append("hide", target_args(to, global:, transition:, expanded:))
142
+ def hide(to, global: false, transition: nil, expanded: nil, keep: false)
143
+ append("hide", target_args(to, global:, transition:, expanded:, keep:))
128
144
  end
129
145
 
130
- def toggle(to, global: false, transition: nil, expanded: nil)
131
- append("toggle", target_args(to, global:, transition:, expanded:))
146
+ def toggle(to, global: false, transition: nil, expanded: nil, keep: false)
147
+ append("toggle", target_args(to, global:, transition:, expanded:, keep:))
132
148
  end
133
149
 
134
150
  # --- Classes ---
135
151
 
136
- def add_class(to, *classes, global: false)
137
- append("add_class", class_args(to, classes, global:))
152
+ def add_class(to, *classes, global: false, keep: false)
153
+ append("add_class", class_args(to, classes, global:, keep:))
138
154
  end
139
155
 
140
- def remove_class(to, *classes, global: false)
141
- append("remove_class", class_args(to, classes, global:))
156
+ def remove_class(to, *classes, global: false, keep: false)
157
+ append("remove_class", class_args(to, classes, global:, keep:))
142
158
  end
143
159
 
144
- def toggle_class(to, *classes, global: false)
145
- append("toggle_class", class_args(to, classes, global:))
160
+ def toggle_class(to, *classes, global: false, keep: false)
161
+ append("toggle_class", class_args(to, classes, global:, keep:))
146
162
  end
147
163
 
148
164
  # --- Attributes (issue #96) — allowlisted names only ---
@@ -157,19 +173,19 @@ module Phlex
157
173
  # attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
158
174
  # js.toggle_attr("#trigger", "aria-expanded", "true", "false")
159
175
 
160
- def set_attr(to, name, value, global: false)
161
- append("set_attr", attr_args(to, name, global:, value:))
176
+ def set_attr(to, name, value, global: false, keep: false)
177
+ append("set_attr", attr_args(to, name, global:, keep:, value:))
162
178
  end
163
179
 
164
- def remove_attr(to, name, global: false)
165
- append("remove_attr", attr_args(to, name, global:))
180
+ def remove_attr(to, name, global: false, keep: false)
181
+ append("remove_attr", attr_args(to, name, global:, keep:))
166
182
  end
167
183
 
168
- def toggle_attr(to, name, *values, global: false)
169
- args = attr_args(to, name, global:)
184
+ def toggle_attr(to, name, *values, global: false, keep: false)
185
+ args = attr_args(to, name, global:, keep:)
170
186
  return append("toggle_attr", args) if values.empty?
171
187
 
172
- append("toggle_attr", args.merge("values" => toggle_values(name, values)).freeze)
188
+ append("toggle_attr", with_keep(args.except("keep").merge("values" => toggle_values(name, values)), keep))
173
189
  end
174
190
 
175
191
  # --- Focus (issue #96) ---
@@ -256,7 +272,7 @@ module Phlex
256
272
  # element (the two passes would fight over the same attribute).
257
273
 
258
274
  def paste_into(to, global: false)
259
- if to == :root
275
+ if [:root, ROOT_SENTINEL].include?(to)
260
276
  raise ArgumentError,
261
277
  "#{self.class}: paste_into targets a field, not the component root — " \
262
278
  "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
@@ -265,6 +281,36 @@ module Phlex
265
281
  append("paste_into", target_args(to, global:))
266
282
  end
267
283
 
284
+ # --- Preview a chosen file (issue #375) ---
285
+ #
286
+ # preview_file(to, into:, name: nil) — on the file input's change, paint
287
+ # the first chosen IMAGE file onto the `into:` img through an object URL
288
+ # the client creates itself (URL.createObjectURL) and revokes on the next
289
+ # choice, on a clear, and when the img leaves the page. The op carries NO
290
+ # URL — there is no parameter for one, and the client never reads a `url`
291
+ # key — so the `src` refusal of set_attr (issue #96) stays whole. `to` is
292
+ # an input[type=file] (an explicit :root is refused); `into:` must
293
+ # resolve to an <img> (anything else warns and is skipped in the
294
+ # browser); `name:` optionally names a node that gets file.name as
295
+ # textContent. An empty selection or a non-image file clears the preview.
296
+ # ACTOR-ONLY like paste_into: refused in broadcast_to(js:) — a broadcast
297
+ # that read every subscriber's file input would be hostile.
298
+ # input(type: "file", id: "photo-file", accept: "image/*",
299
+ # **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
300
+
301
+ def preview_file(to, into:, name: nil, global: false)
302
+ if [:root, ROOT_SENTINEL].include?(to)
303
+ raise ArgumentError,
304
+ "#{self.class}: preview_file reads a file input, not the component root — " \
305
+ "pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
306
+ end
307
+
308
+ args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
309
+ args["name"] = preview_selector(:name, name) unless name.nil?
310
+ args["global"] = true if global
311
+ append("preview_file", args.freeze)
312
+ end
313
+
268
314
  # --- Text content (issue #159) ---
269
315
  #
270
316
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -279,6 +325,56 @@ module Phlex
279
325
  append("text", args.freeze)
280
326
  end
281
327
 
328
+ # --- Copy a server-rendered image (issue #373) ---
329
+ #
330
+ # copy_media(to, from:) — copy src, srcset and alt from the `from` <img>
331
+ # onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
332
+ # resolved with the op's own scoping, global: included): the URL is read
333
+ # off the live source element in the browser and is never an argument,
334
+ # so the set_attr refusal of src (issue #96) stays. A missing source, a
335
+ # non-<img> source or one without src clears the target's src, srcset
336
+ # and alt; a target that is not an <img> is skipped. The same URL twice
337
+ # is not written again (no reload). Broadcast-safe: it only reads
338
+ # elements already in the subscriber's document.
339
+ # button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
340
+
341
+ def copy_media(to, from:, global: false)
342
+ source = normalize_target(from)
343
+ raise ArgumentError, "#{self.class}: copy_media from: needs a CSS selector, got an empty string" if source.empty?
344
+
345
+ args = { "to" => normalize_target(to), "from" => source }
346
+ args["global"] = true if global
347
+ append("copy_media", args.freeze)
348
+ end
349
+
350
+ # --- A field's live value (issue #374) ---
351
+ #
352
+ # set_value(to, value) — assign the target field's IDL `.value` (never
353
+ # the attribute: that is set_attr, and a field the user typed in does not
354
+ # follow it), then dispatch a bubbling `input` when the value changed, so
355
+ # reducers and reactive_show see it as typed — the set-value contract
356
+ # paste_into uses (issue #183). Never `change`, never focus (chain
357
+ # js.focus for that), never parsed as HTML. The value is stringified; nil
358
+ # and "" clear the field. The target is an input, textarea or select:
359
+ # :root is refused loudly like paste_into, and anything else (a
360
+ # contenteditable — reactive_editable writes those — a non-field, a
361
+ # non-empty value for a file input) warns and skips in the browser.
362
+ # ACTOR-ONLY like paste_into: a broadcast that overwrote every
363
+ # subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
364
+ # button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
365
+
366
+ def set_value(to, value, global: false)
367
+ if [:root, ROOT_SENTINEL].include?(to)
368
+ raise ArgumentError,
369
+ "#{self.class}: set_value targets a field, not the component root — " \
370
+ "pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))"
371
+ end
372
+
373
+ args = { "to" => normalize_target(to), "value" => value.to_s }
374
+ args["global"] = true if global
375
+ append("set_value", args.freeze)
376
+ end
377
+
282
378
  # --- Tick or untick a checkbox group (issue #342) ---
283
379
  #
284
380
  # check_group(group, checked = true) — set every OWNED box of the group
@@ -395,14 +491,31 @@ module Phlex
395
491
  self.class.new([*@ops, [name, args].freeze].freeze)
396
492
  end
397
493
 
398
- def target_args(to, global:, transition: nil, expanded: nil)
494
+ def target_args(to, global:, transition: nil, expanded: nil, keep: false)
399
495
  args = { "to" => normalize_target(to) }
400
496
  args["global"] = true if global
401
497
  args["transition"] = normalize_transition(transition) if transition
402
498
  args["expanded"] = normalize_target(expanded) unless expanded.nil?
499
+ with_keep(args, keep)
500
+ end
501
+
502
+ # keep: (issue #369) rides LAST and only when true, so an op without it
503
+ # serializes byte-identically to before.
504
+ def with_keep(args, keep)
505
+ args["keep"] = true if keep
403
506
  args.freeze
404
507
  end
405
508
 
509
+ # preview_file's into:/name: — a non-empty CSS selector String, never
510
+ # :root or its "@root" sentinel (the root is a container, not the img —
511
+ # and a name: on it would replace the whole component with a file name).
512
+ def preview_selector(key, selector)
513
+ return selector if selector.is_a?(String) && !selector.strip.empty? && selector != ROOT_SENTINEL
514
+
515
+ raise ArgumentError,
516
+ "#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
517
+ end
518
+
406
519
  # The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
407
520
  # half-specified pair or one that could never change anything.
408
521
  def toggle_values(name, values)
@@ -422,13 +535,13 @@ module Phlex
422
535
 
423
536
  # An attr op's args: the target, the allowlisted name, an optional
424
537
  # (stringified) value. The name is validated LOUDLY at build time.
425
- def attr_args(to, name, global:, value: :__none)
538
+ def attr_args(to, name, global:, keep: false, value: :__none)
426
539
  name = name.to_s
427
540
  assert_allowed_attr(name)
428
541
  args = { "to" => normalize_target(to), "name" => name }
429
542
  args["value"] = value.to_s unless value == :__none
430
543
  args["global"] = true if global
431
- args.freeze
544
+ with_keep(args, keep)
432
545
  end
433
546
 
434
547
  # Build-time attr-name allowlist for the instance builder — delegates to the
@@ -456,14 +569,14 @@ module Phlex
456
569
  [transition[:during], transition[:from], transition[:to]].map(&:to_s).freeze
457
570
  end
458
571
 
459
- def class_args(to, classes, global:)
572
+ def class_args(to, classes, global:, keep: false)
460
573
  if classes.empty?
461
574
  raise ArgumentError, "#{self.class}: a class op needs at least one class (got none for #{to.inspect})"
462
575
  end
463
576
 
464
577
  args = { "to" => normalize_target(to), "classes" => classes.map(&:to_s).freeze }
465
578
  args["global"] = true if global
466
- args.freeze
579
+ with_keep(args, keep)
467
580
  end
468
581
 
469
582
  # Instance-side alias for the class method (used by the op builder). See
@@ -40,13 +40,21 @@ module Phlex
40
40
  # (issue #342) would tick or clear every subscriber's selection; presence
41
41
  # (issue #358) would write every subscriber's presence state; show_modal
42
42
  # and close_dialog (issue #365) would open or close a modal, and move
43
- # focus, on every screen at once. They
43
+ # focus, on every screen at once; set_value (issue #374) would overwrite
44
+ # every subscriber's field; preview_file (issue #375) would read every
45
+ # subscriber's file input. They
44
46
  # belong to the actor's own reply (reply.js) or gesture (on_client / a
45
47
  # reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
46
48
  BROADCAST_REFUSED_OPS = %w[
47
49
  focus focus_first submit paste_into persist_state persist_clear check_group presence
48
50
  show_modal close_dialog
51
+ preview_file
52
+ set_value
49
53
  ].freeze
54
+ # Refused as a FLAG, on any op (issue #369): keep: records client state in
55
+ # the tab that ran the op, so a broadcast carrying it would plant that
56
+ # state in every subscriber's tab. The op itself broadcasts fine without it.
57
+ BROADCAST_REFUSED_ARG = "keep"
50
58
 
51
59
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
52
60
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -201,14 +209,30 @@ module Phlex
201
209
  "subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
202
210
  "broadcasting check_group ticks or clears every subscriber's selection; " \
203
211
  "broadcasting presence writes every subscriber's presence state; " \
212
+ "broadcasting set_value overwrites every subscriber's field (render the value instead); " \
204
213
  "broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
205
- "every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead." \
214
+ "every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead. " \
215
+ "Broadcasting preview_file reads every subscriber's chosen file onto their page. " \
206
216
  "These are actor concerns (reply.js / on_client / $ops)."
207
217
  end
218
+ refuse_kept_broadcast_ops!(pairs)
208
219
 
209
220
  Phlex::Reactive::Response.js_ops_json(ops)
210
221
  end
211
222
 
223
+ # keep: (issue #369) is per-tab client state: refuse the flag on any op.
224
+ def refuse_kept_broadcast_ops!(pairs)
225
+ kept = pairs.filter_map do |name, args|
226
+ name.to_s if args.is_a?(::Hash) && (args[BROADCAST_REFUSED_ARG] || args[BROADCAST_REFUSED_ARG.to_sym])
227
+ end
228
+ return if kept.empty?
229
+
230
+ raise ArgumentError,
231
+ "broadcast_to(js:) refuses keep: (on #{kept.uniq.join(", ")}) — kept state belongs to the tab " \
232
+ "that ran the op; broadcasting it would plant it in every subscriber's tab. Drop keep: from the " \
233
+ "broadcast, or render the state on the server."
234
+ end
235
+
212
236
  # Render a built component to HTML for a broadcast, ALWAYS instrumented
213
237
  # (issue #185): a Streamable renders through its own render_component (which
214
238
  # fires render.phlex_reactive); a plain component renders through the module
@@ -93,14 +93,23 @@ module Phlex
93
93
  # after_commit broadcasts behave.
94
94
  # 5. authorization — a registered authorization error RAISES (the endpoint
95
95
  # maps it to 403; a unit test asserts the real exception).
96
- def run_reactive(component, action, **params)
96
+ #
97
+ # `held:` stands in for the X-Reactive-Held header (issue #370): the field
98
+ # names a morph held over a changed server value, filtered to the action's
99
+ # declared params exactly as the endpoint filters them, and returned by
100
+ # reactive_held_fields inside the action. `held` is therefore reserved
101
+ # here: an action param literally named `held` can't be passed through.
102
+ #
103
+ # run_reactive(form, :save, held: ["title"], title: "Draft by B")
104
+ def run_reactive(component, action, held: [], **params)
97
105
  klass = component.class
98
106
  action_def = klass.reactive_actions[action.to_sym] || raise_undeclared(klass, action)
99
107
 
100
108
  rebuilt = rebuild_from_identity(component, klass)
101
109
  coerced = action_def.schema.coerce(stringify_params(params))
102
110
 
103
- returned = run_reactive_action(rebuilt, action_def, coerced)
111
+ held_fields = Phlex::Reactive::HeldFields.parse(Array(held).map(&:to_s).to_json, action_def)
112
+ returned = Phlex::Reactive::HeldFields.with(held_fields) { run_reactive_action(rebuilt, action_def, coerced) }
104
113
  Result.new(returned:, component: rebuilt)
105
114
  end
106
115
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.1"
5
+ VERSION = "0.15.2"
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.15.1
4
+ version: 0.15.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -198,6 +198,7 @@ files:
198
198
  - lib/phlex/reactive/component/helpers.rb
199
199
  - lib/phlex/reactive/component/identity.rb
200
200
  - lib/phlex/reactive/component/lazy.rb
201
+ - lib/phlex/reactive/component/media_binding.rb
201
202
  - lib/phlex/reactive/component/presence_declaration.rb
202
203
  - lib/phlex/reactive/component/registry.rb
203
204
  - lib/phlex/reactive/component/selection.rb
@@ -210,6 +211,7 @@ files:
210
211
  - lib/phlex/reactive/effects.rb
211
212
  - lib/phlex/reactive/engine.rb
212
213
  - lib/phlex/reactive/fragment.rb
214
+ - lib/phlex/reactive/held_fields.rb
213
215
  - lib/phlex/reactive/inspector.rb
214
216
  - lib/phlex/reactive/inspector/report.rb
215
217
  - lib/phlex/reactive/js.rb