phlex-reactive 0.15.2 → 0.15.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.
@@ -419,6 +419,135 @@ export function __resetReactiveKeepForTest() {
419
419
  keepRegistered = false
420
420
  }
421
421
 
422
+ // --- An image fallback that recovers (issue #383) ---------------------------
423
+ // data-reactive-image-fallback='{"hide":true,"alt":"#name"}' on an <img>. On
424
+ // `error` it gets data-reactive-image-state="error" (plus `hidden` with hide,
425
+ // and its alt shown in the `alt` node of its own reactive root); on `load`,
426
+ // "loaded". A later src clears the error: copy_media and preview_file reset
427
+ // it as they write (resetImage), any src that loads clears it on `load`, and
428
+ // a morph that changes the src lets the browser try again. A morph that keeps
429
+ // the src puts the state back. An error with no src is not an error (a
430
+ // removed or emptied src). One document listener, capture phase (neither
431
+ // event bubbles); nothing per root at connect.
432
+ const IMAGE_FALLBACK = "data-reactive-image-fallback"
433
+ const IMAGE_STATE = "data-reactive-image-state"
434
+ // Marks the `hidden` the fallback set, so a reset removes only that one. On
435
+ // the element, not in a WeakSet: a Turbo snapshot restores a NEW node.
436
+ const IMAGE_HIDDEN = "data-reactive-image-hidden"
437
+ let imageFallbackListeners = null
438
+
439
+ function imageFallback(img) {
440
+ if (img?.localName !== "img") return null
441
+ const raw = img.getAttribute(IMAGE_FALLBACK)
442
+ if (raw == null) return null
443
+ try {
444
+ return JSON.parse(raw) || {}
445
+ } catch {
446
+ return {}
447
+ }
448
+ }
449
+
450
+ const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset")
451
+
452
+ // The node that shows the alt text: a selector resolved inside the img's own
453
+ // root only (a document listener has no op scope).
454
+ function imageAltNode(img, wire) {
455
+ if (typeof wire.alt !== "string" || !wire.alt) return null
456
+ try {
457
+ const node = img.closest('[data-controller~="reactive"]')?.querySelector(wire.alt)
458
+ // Never a node that holds the img: its textContent would wipe the img.
459
+ return node && !node.contains(img) ? node : null
460
+ } catch {
461
+ return null
462
+ }
463
+ }
464
+
465
+ function setImageState(img, wire, value) {
466
+ if (value === "error") {
467
+ if (wire.hide && !img.hasAttribute("hidden")) {
468
+ img.setAttribute("hidden", "")
469
+ img.setAttribute(IMAGE_HIDDEN, "")
470
+ }
471
+ const node = imageAltNode(img, wire)
472
+ if (node) {
473
+ node.textContent = img.getAttribute("alt") ?? ""
474
+ node.hidden = false
475
+ }
476
+ } else resetImage(img, wire)
477
+ img.setAttribute(IMAGE_STATE, value)
478
+ }
479
+
480
+ // Undo what an error did — only what it did: `hidden` stays when the author
481
+ // set it. Called by copy_media and preview_file when they change the src.
482
+ function resetImage(img, wire = imageFallback(img)) {
483
+ if (!wire || img.getAttribute(IMAGE_STATE) == null) return
484
+ if (img.getAttribute(IMAGE_STATE) === "error") {
485
+ if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
486
+ const node = imageAltNode(img, wire)
487
+ if (node) node.hidden = true
488
+ }
489
+ img.removeAttribute(IMAGE_STATE)
490
+ img.removeAttribute(IMAGE_HIDDEN)
491
+ }
492
+
493
+ function onImageEvent(event) {
494
+ const img = event.target
495
+ const wire = imageFallback(img)
496
+ if (!wire) return
497
+ if (event.type === "load") setImageState(img, wire, "loaded")
498
+ else if (hasImageSrc(img)) setImageState(img, wire, "error")
499
+ else resetImage(img, wire)
500
+ }
501
+
502
+ export function registerReactiveImageFallback() {
503
+ if (imageFallbackListeners) return
504
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
505
+ // A morph that keeps the src keeps the state: the render carries none, and
506
+ // the browser loads nothing new to tell us again. Put it back once the
507
+ // whole morph is done, so a later-morphed alt node is shown again too.
508
+ const kept = new WeakMap()
509
+ imageFallbackListeners = {
510
+ error: onImageEvent,
511
+ load: onImageEvent,
512
+ "turbo:before-morph-element": (event) => {
513
+ const img = event.target
514
+ const next = event.detail?.newElement
515
+ const value = img?.getAttribute?.(IMAGE_STATE)
516
+ if (value == null || !next || !imageFallback(img)) return
517
+ const same = (name) => img.getAttribute(name) === next.getAttribute?.(name)
518
+ if (same("src") && same("srcset")) kept.set(img, value)
519
+ },
520
+ "turbo:morph-element": (event) => {
521
+ const img = event.target
522
+ const value = kept.get(img)
523
+ if (value === undefined) return
524
+ kept.delete(img)
525
+ queueMicrotask(() => {
526
+ const wire = imageFallback(img)
527
+ if (wire) setImageState(img, wire, value)
528
+ })
529
+ },
530
+ }
531
+ for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
532
+ // An image that finished before this ran (a cached 404) fired its event
533
+ // unheard: read it off the element once. naturalWidth 0 is also a loaded
534
+ // SVG with only a viewBox, so decode() decides (it rejects a broken image).
535
+ for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
536
+ if (!img.complete || !hasImageSrc(img)) continue
537
+ // A decode() that settles after the src changed decoded the OLD one: ignore it.
538
+ const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
539
+ const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
540
+ if (img.naturalWidth > 0) mark("loaded")
541
+ else if (typeof img.decode === "function") img.decode().then(() => mark("loaded"), () => mark("error"))
542
+ else mark("error")
543
+ }
544
+ }
545
+
546
+ export function __resetReactiveImageFallbackForTest() {
547
+ for (const [name, fn] of Object.entries(imageFallbackListeners ?? {})) document.removeEventListener(name, fn, true)
548
+ imageFallbackListeners = null
549
+ }
550
+
422
551
  // For the opt-in entry: what to do with a stream before its render — hold it
423
552
  // for a module the incoming content needs. `handle(event, effects)` gets the
424
553
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -860,6 +989,7 @@ export function registerReactiveActions() {
860
989
  registerReactiveMorphFocus()
861
990
  registerReactiveModals()
862
991
  registerReactiveKeep()
992
+ registerReactiveImageFallback()
863
993
  registerReactivePlaintext()
864
994
  registerReactiveOffline()
865
995
  registerReactiveDev()
@@ -1296,7 +1426,11 @@ function previewFile(input, args, resolveTargets) {
1296
1426
  if (img?.tagName !== "IMG") {
1297
1427
  return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
1298
1428
  }
1299
- const file = [...(input.files ?? [])].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1429
+ // valid: true (issue #382): an input the app marked invalid (setCustomValidity,
1430
+ // required) previews as empty. validity.valid, not checkValidity(): no
1431
+ // `invalid` event fired into the app's own listeners.
1432
+ const files = args.valid === true && input.validity?.valid === false ? [] : (input.files ?? [])
1433
+ const file = [...files].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1300
1434
  forgetPreview(img)
1301
1435
  if (file) {
1302
1436
  const url = URL.createObjectURL(file)
@@ -1304,6 +1438,7 @@ function previewFile(input, args, resolveTargets) {
1304
1438
  img.setAttribute("src", url)
1305
1439
  watchPreviews()
1306
1440
  } else img.removeAttribute("src")
1441
+ resetImage(img)
1307
1442
  if (args.name == null) return
1308
1443
  for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1309
1444
  }
@@ -1404,7 +1539,9 @@ function guardAttr(name) {
1404
1539
  // copy_media op and the reactive_media binding (the bindings feature reaches it
1405
1540
  // through the core handle). Only an <img> target is written, so an iframe,
1406
1541
  // script, object, embed or frame never receives a src from here. A source that
1407
- // is missing, not an <img> or has no src clears the target. Each attribute is
1542
+ // is missing or not an <img> clears the target. An <img> source without src is
1543
+ // a hit with no image (issue #381): the target loses src and srcset but takes
1544
+ // the alt, so a known entry without a logo keeps its name. Each attribute is
1408
1545
  // written only when it changes, so the same URL twice does not reload. Returns
1409
1546
  // the alt copied, false when it cleared, or null when the target was skipped.
1410
1547
  function copyMedia(target, source) {
@@ -1412,17 +1549,19 @@ function copyMedia(target, source) {
1412
1549
  console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
1413
1550
  return null
1414
1551
  }
1415
- const src = source?.localName === "img" ? source.getAttribute("src") : null
1416
- const pick = (name) => (src ? source.getAttribute(name) : null)
1552
+ const img = source?.localName === "img" ? source : null
1553
+ const src = img?.getAttribute("src")
1554
+ let changed = false
1417
1555
  for (const name of ["srcset", "src"]) {
1418
- const value = pick(name)
1556
+ const value = src ? img.getAttribute(name) : null
1419
1557
  if (value == null) {
1420
- if (target.hasAttribute(name)) target.removeAttribute(name)
1421
- } else if (target.getAttribute(name) !== value) target.setAttribute(name, value)
1558
+ if (target.hasAttribute(name)) changed = target.removeAttribute(name) ?? true
1559
+ } else if (target.getAttribute(name) !== value) changed = target.setAttribute(name, value) ?? true
1422
1560
  }
1423
- const alt = pick("alt") ?? ""
1561
+ if (changed) resetImage(target)
1562
+ const alt = img?.getAttribute("alt") ?? ""
1424
1563
  if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
1425
- return src ? alt : false
1564
+ return img ? alt : false
1426
1565
  }
1427
1566
 
1428
1567
  // The first focusable descendant of `el`, in document order — the natural
@@ -16,6 +16,7 @@ module Phlex
16
16
  include Sortable
17
17
  include Editable
18
18
  include MediaBinding
19
+ include ImageFallbackBinding
19
20
 
20
21
  # The acting client's SSE connection id during the current action (nil
21
22
  # outside an action, or when the client isn't subscribed to a stream).
@@ -1766,13 +1767,16 @@ module Phlex
1766
1767
 
1767
1768
  private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
1768
1769
 
1770
+ # Runs on every on() (a hot path): a key lookup per removed name, no arrays.
1769
1771
  def reject_removed_on_kwargs!(action_name, params)
1770
- removed = params.keys & REMOVED_ON_KWARGS.keys
1771
- return if removed.empty?
1772
+ return if params.empty?
1772
1773
 
1773
- key = removed.first
1774
- raise ArgumentError,
1775
- "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
1774
+ REMOVED_ON_KWARGS.each_key do
1775
+ next unless params.key?(it)
1776
+
1777
+ raise ArgumentError,
1778
+ "on(#{action_name.inspect}) #{it}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[it]}"
1779
+ end
1776
1780
  end
1777
1781
 
1778
1782
  # Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
@@ -18,6 +18,16 @@ module Phlex
18
18
  module Identity
19
19
  extend ActiveSupport::Concern
20
20
 
21
+ # Whether the installed GlobalID gives a class its own app
22
+ # (global_id_app, then GlobalID.app — rails/globalid after 1.4).
23
+ # Read once: it is a fact about the loaded gem.
24
+ def self.per_class_gid_app?
25
+ return @per_class_gid_app if defined?(@per_class_gid_app)
26
+
27
+ @per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
28
+ ::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
29
+ end
30
+
21
31
  class_methods do
22
32
  # The record's instance-variable symbol (e.g. :@todo), computed once.
23
33
  # reactive_token reads it on every render; interpolating :"@#{key}" each
@@ -74,7 +84,7 @@ module Phlex
74
84
  # before to_gid (nil.to_gid would raise).
75
85
  if (record_ivar = klass.reactive_record_ivar) &&
76
86
  (record = instance_variable_get(record_ivar)) && signable_gid?(record)
77
- payload["gid"] = record.to_gid.to_s
87
+ payload["gid"] = reactive_gid(record)
78
88
  end
79
89
 
80
90
  state_ivars = klass.reactive_state_ivars
@@ -95,6 +105,29 @@ module Phlex
95
105
  def signable_gid?(record)
96
106
  !(record.respond_to?(:persisted?) && !record.persisted?)
97
107
  end
108
+
109
+ # record.to_gid.to_s, without the URI. For a record whose to_gid is
110
+ # GlobalID's own (not overridden on its class or on itself) and whose
111
+ # id is an Integer, GlobalID's string is exactly
112
+ # "gid://<GlobalID.app>/<class name>/<id>": an Integer needs no
113
+ # escaping and there are no params. Building it directly skips a
114
+ # validated URI::GID (~37 objects, about half a record token's cost),
115
+ # once per record-backed row per render. Anything else goes through
116
+ # to_gid unchanged, its errors included.
117
+ def reactive_gid(record)
118
+ if record.is_a?(::GlobalID::Identification) &&
119
+ record.method(:to_gid).owner.equal?(::GlobalID::Identification) &&
120
+ (id = record.id).is_a?(Integer) && (app = reactive_gid_app(record.class)) && (name = record.class.name)
121
+ "gid://#{app}/#{name}/#{id}"
122
+ else
123
+ record.to_gid.to_s
124
+ end
125
+ end
126
+
127
+ # The app to_gid would use: the class's own where GlobalID has one.
128
+ def reactive_gid_app(klass)
129
+ (Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
130
+ end
98
131
  end
99
132
  end
100
133
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # An <img> that falls back when its src fails and recovers when the src
7
+ # changes (issue #383).
8
+ #
9
+ # img(id: "logo", alt: "", **reactive_image_fallback(hide: true))
10
+ # img(id: "logo", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
11
+ # span(id: "bank-name", hidden: true)
12
+ #
13
+ # On `error` the client sets data-reactive-image-state="error", adds
14
+ # `hidden` (hide: true, the default) and, with show_alt:, writes the
15
+ # img's alt into that node (a CSS selector inside the root) and shows it.
16
+ # On `load` the state is "loaded". A later src — copy_media,
17
+ # reactive_media, preview_file, a morph, or any src that loads — clears
18
+ # the error and shows the img again; an img with no src is never an
19
+ # error. hide: false leaves visibility alone: style the state with CSS.
20
+ # Composes with reactive_media (its **attrs mix the data attribute in):
21
+ #
22
+ # img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
23
+ #
24
+ # Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
25
+ module ImageFallbackBinding
26
+ def reactive_image_fallback(hide: true, show_alt: nil, **attrs)
27
+ wire = { "hide" => image_fallback_hide!(hide) }
28
+ wire["alt"] = image_fallback_alt!(show_alt) unless show_alt.nil?
29
+ mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
30
+ end
31
+
32
+ private
33
+
34
+ def image_fallback_hide!(hide)
35
+ return hide if [true, false].include?(hide)
36
+
37
+ raise ArgumentError, "reactive_image_fallback hide: must be true or false, got #{hide.inspect}"
38
+ end
39
+
40
+ # A selector the client passes to querySelector, never a URL.
41
+ def image_fallback_alt!(selector)
42
+ if selector == JS::ROOT_SENTINEL
43
+ raise ArgumentError,
44
+ "reactive_image_fallback show_alt: #{selector} names the whole root — pass the selector of a node inside it"
45
+ end
46
+ return selector if selector.is_a?(String) && !selector.strip.empty?
47
+
48
+ raise ArgumentError,
49
+ "reactive_image_fallback show_alt: must be a CSS selector (e.g. \"#bank-name\"), got #{selector.inspect}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -20,18 +20,29 @@ module Phlex
20
20
  # first n characters as the key; omit it to use the whole value. A pool
21
21
  # value is a CSS SELECTOR for an <img> inside the root, never a URL: the
22
22
  # client copies that element's src, srcset and alt the way
23
- # js.copy_media does. A key, selector or element that misses clears the
24
- # preview. `label:` names a node whose textContent becomes the chosen
25
- # image's alt (`blank_label`, or "", on a miss). Client-only: no token,
23
+ # js.copy_media does. A key, selector or element that misses (or is not
24
+ # an <img>) clears the preview. A pool <img> with an alt and no src is a
25
+ # hit without an image: the preview drops its src and takes the alt
26
+ # (issue #381). `label:` names a node whose textContent becomes the
27
+ # chosen image's alt (`blank_label`, or "", on a miss). Client-only: no token,
26
28
  # no round trip.
27
29
  #
30
+ # Until the field has `take` characters, or while `match:` (a Regexp,
31
+ # anchored to the whole key) rejects the key, the binding is PENDING
32
+ # (issue #380): never `blank_label`. `pending: :clear` (the default)
33
+ # clears the preview and empties the label; `pending: :keep` leaves both
34
+ # alone. The preview's data-reactive-media-state reads pending, hit or
35
+ # miss, so a row can hide itself with CSS until there is an answer.
36
+ #
28
37
  # Named MediaBinding, not Media: this module sits in every reactive
29
38
  # component's ancestry, and a bare `Media` would shadow a host app's kit
30
39
  # component of that name (issue #233).
31
40
  module MediaBinding
32
- def reactive_media(from:, pool: nil, take: nil, label: nil, blank_label: nil, **attrs)
41
+ def reactive_media(from:, pool: nil, take: nil, match: nil, pending: :clear, label: nil, blank_label: nil, **attrs)
33
42
  wire = { "field" => media_field!(from) }
34
43
  wire["take"] = media_take!(take) unless take.nil?
44
+ wire["match"] = MediaPattern.to_js(match) unless match.nil?
45
+ wire["pending"] = "keep" if media_pending!(pending) == "keep"
35
46
  wire["pool"] = media_pool!(pool)
36
47
  wire["label"] = media_selector!(:label, label) unless label.nil?
37
48
  wire["blank"] = blank_label.to_s unless blank_label.nil?
@@ -54,6 +65,13 @@ module Phlex
54
65
  "got #{take.inspect}"
55
66
  end
56
67
 
68
+ def media_pending!(pending)
69
+ mode = pending.to_s if pending.is_a?(Symbol) || pending.is_a?(String)
70
+ return mode if %w[clear keep].include?(mode)
71
+
72
+ raise ArgumentError, "reactive_media pending: must be :clear or :keep, got #{pending.inspect}"
73
+ end
74
+
57
75
  def media_pool!(pool)
58
76
  if pool.nil?
59
77
  raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # reactive_media's match: (issue #380) — a Ruby Regexp as the JavaScript
7
+ # source the client anchors as ^(?:…)$ and compiles without flags. The
8
+ # source is tokenized (an escape is one token, so an escaped backslash is
9
+ # never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
10
+ # and anything JavaScript would read differently raises at render rather
11
+ # than matching silently wrong in the browser.
12
+ module MediaPattern
13
+ # Escapes Ruby reads as an anchor or a class and JS reads as a letter,
14
+ # plus \s/\S, whose JS class also holds Unicode spaces Ruby's does not.
15
+ RUBY_ONLY_ESCAPES = %w[\A \z \Z \G \h \H \R \X \K \p \P \s \S].freeze
16
+ # The group openers both engines read alike: (?: (?= (?! (?<= (?<! (?<name>
17
+ SHARED_GROUPS = [":", "=", "!", "<"].freeze
18
+ OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
19
+
20
+ module_function
21
+
22
+ def to_js(match)
23
+ unless match.is_a?(Regexp)
24
+ raise ArgumentError,
25
+ "reactive_media match: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
26
+ end
27
+
28
+ unless match.options.nobits?(OPTIONS)
29
+ refuse!(match,
30
+ "has options (i, m, x) the client would read differently — drop them")
31
+ end
32
+
33
+ tokens = match.source.scan(/\\.|./m)
34
+ tokens.shift if tokens.first == "\\A"
35
+ final = tokens.pop if %w[\z \Z].include?(tokens.last)
36
+ check!(tokens, match)
37
+ # Ruby's \Z also matches before one final newline.
38
+ final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
39
+ end
40
+
41
+ def check!(tokens, match)
42
+ in_class = false
43
+ tokens.each_with_index do |token, i|
44
+ if RUBY_ONLY_ESCAPES.include?(token)
45
+ refuse!(match,
46
+ "uses #{token}, which is Ruby-only (JavaScript reads it differently)")
47
+ end
48
+ refuse!(match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
49
+ if token == "\\u" && tokens[i + 1] == "{"
50
+ refuse!(match,
51
+ "uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
52
+ end
53
+ if in_class
54
+ in_class = false if token == "]"
55
+ class_syntax!(tokens, i, match)
56
+ elsif token == "["
57
+ in_class = true
58
+ elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
59
+ refuse!(match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
60
+ end
61
+ end
62
+ end
63
+
64
+ # A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
65
+ # intersection) are Ruby-only: JavaScript reads them as literals.
66
+ def class_syntax!(tokens, index, match)
67
+ token = tokens[index]
68
+ return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
69
+
70
+ refuse!(match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
71
+ end
72
+
73
+ def refuse!(match, why)
74
+ raise ArgumentError, "reactive_media match: #{match.inspect} #{why}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -248,7 +248,15 @@ module Phlex
248
248
  # authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
249
249
  # a helper may authorize indirectly, so this is a hint, not a verdict.
250
250
  def authorization_check(components)
251
- suspects = components.flat_map { unauthorized_action_labels(it) }
251
+ infos = nil
252
+ suspects = components.flat_map do
253
+ next [] if component_defines_authorization_method?(it)
254
+
255
+ # Read once, and only when needed: Inspector.components Prism-scans
256
+ # every component's actions.
257
+ infos ||= inspector_infos
258
+ unauthorized_action_labels(it, infos)
259
+ end
252
260
  return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
253
261
 
254
262
  Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
@@ -394,13 +402,11 @@ module Phlex
394
402
  end
395
403
 
396
404
  # "Klass#action" for every declared action on `klass` that (advisory)
397
- # appears unauthorized: the component defines no authorization method in its
398
- # ancestry, the action isn't skipped, and the Prism scan (via the shared
399
- # Inspector) finds no authorization / mark_authorized! call in its body.
400
- def unauthorized_action_labels(klass)
401
- return [] if component_defines_authorization_method?(klass)
402
-
403
- info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
405
+ # appears unauthorized (the caller has already ruled out an authorization
406
+ # method in its ancestry): the action isn't skipped, and the Prism scan (via
407
+ # the shared Inspector) finds no authorization / mark_authorized! call in its body.
408
+ def unauthorized_action_labels(klass, infos)
409
+ info = infos[klass]
404
410
  return [] unless info
405
411
 
406
412
  info.actions
@@ -417,6 +423,12 @@ module Phlex
417
423
  # Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
418
424
  # folds in mark_authorized! — every component inherits that instance helper,
419
425
  # so including it would silence the check for everything.
426
+ def inspector_infos
427
+ Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
428
+ rescue StandardError
429
+ {}
430
+ end
431
+
420
432
  def component_defines_authorization_method?(klass)
421
433
  Phlex::Reactive.authorization_methods.any? do
422
434
  klass.method_defined?(it) || klass.private_method_defined?(it)
@@ -297,8 +297,17 @@ module Phlex
297
297
  # that read every subscriber's file input would be hostile.
298
298
  # input(type: "file", id: "photo-file", accept: "image/*",
299
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)
300
+ #
301
+ # Upload validation (issue #382): `valid: true` treats an input whose
302
+ # validity is false (setCustomValidity, required) as empty, so a rejected
303
+ # file never gets a preview. An app that clears the input without a
304
+ # change event dispatches `reactive:file-changed` on it; bind the same op
305
+ # to that event too and the preview clears:
306
+ # op = js.preview_file("#photo-file", into: "#photo", valid: true)
307
+ # input(type: "file", id: "photo-file",
308
+ # **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
309
+
310
+ def preview_file(to, into:, name: nil, global: false, valid: false)
302
311
  if [:root, ROOT_SENTINEL].include?(to)
303
312
  raise ArgumentError,
304
313
  "#{self.class}: preview_file reads a file input, not the component root — " \
@@ -308,6 +317,7 @@ module Phlex
308
317
  args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
309
318
  args["name"] = preview_selector(:name, name) unless name.nil?
310
319
  args["global"] = true if global
320
+ args["valid"] = true if valid
311
321
  append("preview_file", args.freeze)
312
322
  end
313
323
 
@@ -331,9 +341,10 @@ module Phlex
331
341
  # onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
332
342
  # resolved with the op's own scoping, global: included): the URL is read
333
343
  # 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
344
+ # so the set_attr refusal of src (issue #96) stays. A missing or
345
+ # non-<img> source clears the target's src, srcset and alt; an <img>
346
+ # source without src removes the target's src and srcset and copies its
347
+ # alt (issue #381); a target that is not an <img> is skipped. The same URL twice
337
348
  # is not written again (no reload). Broadcast-safe: it only reads
338
349
  # elements already in the subscriber's document.
339
350
  # button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.2"
5
+ VERSION = "0.15.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.15.2
4
+ version: 0.15.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -197,8 +197,10 @@ files:
197
197
  - lib/phlex/reactive/component/editable.rb
198
198
  - lib/phlex/reactive/component/helpers.rb
199
199
  - lib/phlex/reactive/component/identity.rb
200
+ - lib/phlex/reactive/component/image_fallback_binding.rb
200
201
  - lib/phlex/reactive/component/lazy.rb
201
202
  - lib/phlex/reactive/component/media_binding.rb
203
+ - lib/phlex/reactive/component/media_pattern.rb
202
204
  - lib/phlex/reactive/component/presence_declaration.rb
203
205
  - lib/phlex/reactive/component/registry.rb
204
206
  - lib/phlex/reactive/component/selection.rb