phlex-reactive 0.16.8 → 0.16.10

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.
@@ -449,7 +449,9 @@ export function __resetReactiveKeepForTest() {
449
449
  // a morph that changes the src lets the browser try again. A morph that keeps
450
450
  // the src puts the state back. An error with no src is not an error (a
451
451
  // removed or emptied src). One document listener, capture phase (neither
452
- // event bubbles); nothing per root at connect.
452
+ // event bubbles); nothing per root at connect. An img that finished before it
453
+ // was in the document (before registration, or while Turbo held it imported
454
+ // but not yet inserted, issue #452) is read off the element instead.
453
455
  const IMAGE_FALLBACK = "data-reactive-image-fallback"
454
456
  const IMAGE_STATE = "data-reactive-image-state"
455
457
  // Marks the `hidden` the fallback set, so a reset removes only that one. On
@@ -580,13 +582,36 @@ export function registerReactiveImageFallback() {
580
582
  if (wire) setImageState(img, wire, value)
581
583
  })
582
584
  },
585
+ // Content Turbo inserts was imported into the document first, a few awaits
586
+ // earlier (issue #452): an img loads from then on, so one whose request
587
+ // ended in between fired its event detached, unheard. Read it off the
588
+ // element once the content is in: the frame, the page, a stream's render.
589
+ "turbo:frame-render": (event) => scanImageFallbacks(event.target),
590
+ "turbo:render": () => scanImageFallbacks(document),
591
+ "turbo:before-stream-render": (event) => {
592
+ const detail = event.detail
593
+ const render = detail?.render
594
+ const stream = detail?.newStream ?? event.target
595
+ if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.(`img[${IMAGE_FALLBACK}]`)) return
596
+ // The whole document: a replace's target is gone by then.
597
+ detail.render = async (el) => {
598
+ await render(el)
599
+ scanImageFallbacks(document)
600
+ }
601
+ },
583
602
  }
584
603
  for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
585
- // An image that finished before this ran (a cached 404) fired its event
586
- // unheard: read it off the element once. naturalWidth 0 is also a loaded
587
- // SVG with only a viewBox, so decode() decides (it rejects a broken image).
588
- for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
589
- if (!img.complete || !hasImageSrc(img)) continue
604
+ // An image that finished before this ran (a cached 404) fired its event unheard.
605
+ scanImageFallbacks(document)
606
+ }
607
+
608
+ // Read the result of every marked img in `root` that has no state yet and has
609
+ // finished; one still loading fires its own event, connected. naturalWidth 0
610
+ // is also a loaded SVG with only a viewBox, so decode() decides (it rejects a
611
+ // broken image, and fetches nothing).
612
+ function scanImageFallbacks(root) {
613
+ for (const img of root?.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
614
+ if (img.getAttribute(IMAGE_STATE) != null || !img.complete || !hasImageSrc(img)) continue
590
615
  // A decode() that settles after the src changed decoded the OLD one: ignore it.
591
616
  const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
592
617
  const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
@@ -1972,13 +1997,15 @@ function bindingsAlreadyRan(event, controller) {
1972
1997
  function applyOps(list, resolveTargets, onZeroTargets) {
1973
1998
  for (const entry of list) {
1974
1999
  if (!Array.isArray(entry)) continue
1975
- const [name, args = {}] = entry
2000
+ let [name, args = {}] = entry
1976
2001
  if (!Object.hasOwn(CLIENT_OPS, name)) {
1977
2002
  console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1978
2003
  continue
1979
2004
  }
1980
2005
  const targets = resolveTargets(args)
1981
2006
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
2007
+ if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
2008
+ if (!args) continue
1982
2009
  for (const el of targets) {
1983
2010
  CLIENT_OPS[name](el, args, resolveTargets)
1984
2011
  if (args?.keep === true) keepResult(el, name, args, resolveTargets)
@@ -1986,6 +2013,48 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1986
2013
  }
1987
2014
  }
1988
2015
 
2016
+ // --- Reads (issue #442) ----------------------------------------------------
2017
+ // set_value, text and set_attr with `from` + `attr` take their value off an
2018
+ // element instead of a literal; dispatch's detail_from builds the detail off
2019
+ // the trigger. The read runs ONCE per op, before any target is written, with
2020
+ // the op's own scoping ("@trigger", "@root", a selector, global:), first
2021
+ // match. Returns the args with the value (or detail) in place, or null to skip
2022
+ // the op: a source that resolves to nothing (the zero-target diagnostic) or a
2023
+ // read attr off the data-*/text rule (warned). copy_media's `from` is an
2024
+ // element it copies, not a read, so it never comes here.
2025
+ const READ_OPS = new Set(["set_value", "text", "set_attr"])
2026
+
2027
+ function readArgs(name, args, resolveTargets, onZeroTargets) {
2028
+ const detail = name === "dispatch"
2029
+ const reads = detail ? args.detail_from : READ_OPS.has(name) && args.from != null && { value: args.attr }
2030
+ if (!reads) return args
2031
+ const from = detail ? "@trigger" : args.from
2032
+ let source
2033
+ // A from that does not parse is a miss like one that matches nothing (the
2034
+ // copy_media precedent): the op is skipped, its siblings still run.
2035
+ try {
2036
+ source = resolveTargets({ ...args, to: from })[0]
2037
+ } catch {}
2038
+ if (!source) return onZeroTargets?.(name, { ...args, to: from }), null
2039
+ const values = detail ? { ...args.detail } : {}
2040
+ for (const [key, attr] of Object.entries(reads)) {
2041
+ const value = readValue(source, attr)
2042
+ if (value == null) return null
2043
+ values[key] = value
2044
+ }
2045
+ return detail ? { ...args, detail: values } : { ...args, ...values }
2046
+ }
2047
+
2048
+ // What a read may take off an element: a data-* attribute (absent reads "",
2049
+ // so it clears) or "@text", its textContent. Anything else warns and is null
2050
+ // — the interpret-time side of JS::READABLE_ATTR (two-sided default-deny).
2051
+ function readValue(el, attr) {
2052
+ if (attr === "@text") return el.textContent ?? ""
2053
+ if (typeof attr === "string" && /^data-[a-z][a-z0-9-]*$/i.test(attr)) return el.getAttribute(attr) ?? ""
2054
+ console.warn(`[phlex-reactive] refused client read of ${JSON.stringify(attr)} — skipped`)
2055
+ return null
2056
+ }
2057
+
1989
2058
  // Resolve a reactive:js op's targets against its `target` root (issue #97).
1990
2059
  // "@root" is the root element itself; a selector resolves WITHIN it; a bare
1991
2060
  // selector with no root (no `target` attr on the stream) resolves document-wide
@@ -1997,6 +2066,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1997
2066
  // documents for the controller path).
1998
2067
  function streamOpTargets(args, root) {
1999
2068
  const to = args.to
2069
+ // A server-pushed op has no trigger (issue #442): the server refuses
2070
+ // "@trigger", and a hand-built one resolves to nothing.
2071
+ if (to === "@trigger") return []
2000
2072
  if (root) {
2001
2073
  if (to === "@root") return [root]
2002
2074
  if (typeof to !== "string" || to === "") return []
@@ -3520,6 +3592,9 @@ export default class extends Controller {
3520
3592
  // A detail miss (issue #346) is a complete no-op — checked before the
3521
3593
  // once record is spent, preventDefault and the confirm gate.
3522
3594
  if (!detailMatches(record, event)) continue
3595
+ // So is a within: miss (issue #442); a hit's row is the ops' @trigger.
3596
+ const row = record.within == null ? trigger : this.#delegatedTrigger(record.within, event)
3597
+ if (record.within != null && !row) continue
3523
3598
  // So is a shortcut that is not this platform's twin or fired in an
3524
3599
  // editable target (issue #357).
3525
3600
  if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
@@ -3531,15 +3606,35 @@ export default class extends Controller {
3531
3606
  this.#runBinding(
3532
3607
  record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
3533
3608
  event,
3534
- trigger,
3609
+ row,
3535
3610
  records.indexOf(record),
3611
+ trigger,
3536
3612
  )
3537
3613
  }
3538
3614
  }
3539
3615
 
3616
+ // Issue #442: a within: record runs for an event from inside an element
3617
+ // matching the selector, strictly under the bound element and owned by this
3618
+ // root (issue #15: a nested root's row is that root's). That element is
3619
+ // returned as the ops' @trigger; anything else is a miss (null). So is a
3620
+ // selector that does not parse (Ruby cannot check CSS), warned under
3621
+ // verbose_errors so the dead binding is never silent in development.
3622
+ #delegatedTrigger(within, event) {
3623
+ const bound = event.currentTarget
3624
+ let row = null
3625
+ try {
3626
+ row = event.target?.closest?.(within)
3627
+ } catch {
3628
+ if (this.#verboseEnabled()) console.warn(`[phlex-reactive] on_client within: ${JSON.stringify(within)} is not a selector — nothing ran`)
3629
+ }
3630
+ return row && row !== bound && bound?.contains?.(row) && this.#ownsField(row) ? row : null
3631
+ }
3632
+
3540
3633
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3541
3634
  // confirm gate, then its ops — every flag read from the binding itself.
3542
- #runBinding(record, event, trigger, ordinal) {
3635
+ // `trigger` is the ops' @trigger and the confirm ctx.el (with within:, the
3636
+ // row); `bound`, the element the binding is on, keys its wait (issue #442).
3637
+ #runBinding(record, event, trigger, ordinal, bound = trigger) {
3543
3638
  const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
3544
3639
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3545
3640
  // outside: trigger is a COMPLETE no-op for events inside this root, before
@@ -3564,7 +3659,7 @@ export default class extends Controller {
3564
3659
  // which must NEVER prompt. The same confirmResolver gate on(:action,
3565
3660
  // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
3566
3661
  const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
3567
- const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
3662
+ const apply = this.#waitedOps(record, ordinal, bound, () => this.#applyOps(this.#parseOps(ops), trigger))
3568
3663
  if (!message) return apply()
3569
3664
  if (message instanceof Promise) {
3570
3665
  return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
@@ -4751,10 +4846,10 @@ export default class extends Controller {
4751
4846
  // nested-reactive-root ownership filter, issue #15). The whitelist + skip
4752
4847
  // logic lives in the shared applyOps so runOps and the reactive:js stream
4753
4848
  // action interpret the SAME vocabulary the SAME way (client-side default-deny).
4754
- #applyOps(list) {
4849
+ #applyOps(list, trigger) {
4755
4850
  applyOps(
4756
4851
  list,
4757
- (args) => this.#opTargets(args),
4852
+ (args) => this.#opTargets(args, trigger),
4758
4853
  (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4759
4854
  )
4760
4855
  }
@@ -4783,13 +4878,17 @@ export default class extends Controller {
4783
4878
  if (__SPLIT__) withFeature("devtools", use)
4784
4879
  }
4785
4880
 
4786
- // Resolve an op's targets: "@root" is this element; a selector resolves
4787
- // WITHIN this root and excludes nested reactive roots' subtrees (issue #15
4788
- // semantics — the same nearest-root ownership check the field walk uses);
4789
- // global: true opts a single op out to document-wide.
4790
- #opTargets(args) {
4881
+ // Resolve an op's targets: "@root" is this element; "@trigger" (issue #442)
4882
+ // the element whose on_client binding fired, when there is one and it is an
4883
+ // element (a window-bound binding's is the window; a reducer's $ops, a
4884
+ // feature's ops and a stream have none); a selector resolves WITHIN this
4885
+ // root and excludes nested reactive roots' subtrees (issue #15 semantics —
4886
+ // the same nearest-root ownership check the field walk uses); global: true
4887
+ // opts a single op out to document-wide.
4888
+ #opTargets(args, trigger) {
4791
4889
  const to = args.to
4792
4890
  if (to === "@root") return [this.element]
4891
+ if (to === "@trigger") return trigger?.nodeType === 1 ? [trigger] : []
4793
4892
  if (typeof to !== "string" || to === "") return []
4794
4893
  if (args.global) return [...document.querySelectorAll(to)]
4795
4894
  return [...this.element.querySelectorAll(to)].filter((el) => this.#ownsField(el))
@@ -657,6 +657,8 @@ module Phlex
657
657
  end
658
658
  raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
659
659
 
660
+ # Issue #442: a completion has no element whose binding fired.
661
+ Phlex::Reactive::JS.refuse_trigger!(pairs, "a reactive_on_complete run")
660
662
  Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
661
663
  pairs
662
664
  end
@@ -504,6 +504,23 @@ module Phlex
504
504
  # on a window-bound binding, an ArgumentError with shortcut:.
505
505
  # div(**on_client(:drop, js.remove_class("#zone", "is-over"), prevent: false))
506
506
  #
507
+ # Issue #442: `within:` makes ONE binding serve a whole subtree — a list
508
+ # of N rows binds once instead of N times. The record runs only for an
509
+ # event from inside an element matching the selector under the bound
510
+ # element (owned by this root: a row of a nested reactive root is that
511
+ # root's), and that element is the ops' `:trigger`; read each row's
512
+ # value off it with from:/attr: (see Phlex::Reactive::JS#set_value):
513
+ # ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value")
514
+ # .set_attr(:trigger, "aria-selected", "true"),
515
+ # within: "[role=option]"))
516
+ # Anywhere else (the list's padding) is a complete no-op: no
517
+ # preventDefault, no confirm, no ops. The bound element is a container,
518
+ # so a click binding is not forced to type="button". window:, outside:
519
+ # and shortcut: raise (a delegated binding is element-bound), and so
520
+ # does once: (Stimulus would drop the listener on the first click, a
521
+ # miss included). A window-bound binding has no trigger element: its
522
+ # `:trigger` ops resolve to nothing (warned under verbose_errors).
523
+ #
507
524
  # Ops are EPHEMERAL UI: any server re-render of the component (an action
508
525
  # reply, a broadcast, a morph) rebuilds from server state and resets
509
526
  # whatever they toggled — by design (the LiveView JS-commands caveat). Use
@@ -513,7 +530,7 @@ module Phlex
513
530
  # accepted — a dead trigger should fail at render, not no-op in the
514
531
  # browser.
515
532
  def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
516
- shortcut: nil, debounce: nil, throttle: nil, prevent: true)
533
+ shortcut: nil, debounce: nil, throttle: nil, prevent: true, within: nil)
517
534
  unless ops.is_a?(Phlex::Reactive::JS)
518
535
  raise ArgumentError,
519
536
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -533,8 +550,10 @@ module Phlex
533
550
  shortcut = client_shortcut!(event, shortcut, window:, outside:)
534
551
  keeps_default!("on_client(#{event.inspect})", prevent, shortcut) unless prevent == true
535
552
  window_bound = window || outside || !shortcut.nil?
553
+ client_within!(event, within, window:, outside:, shortcut:, once:) unless within.nil?
536
554
  keep_default = !prevent && !window_bound
537
- record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
555
+ record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
556
+ within:)
538
557
  client_binding_wait!(record, event, debounce, throttle) unless debounce.nil? && throttle.nil?
539
558
  # The record's `on` stays the bare keydown: the client reads a key
540
559
  # filter from `on`, and `mod` is not a key.
@@ -553,7 +572,7 @@ module Phlex
553
572
  # hidden = !navigator.clipboard.readText. Render the trigger `hidden`
554
573
  # for reveal-when-available (a dead paste button never shows).
555
574
  attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
556
- attrs[:type] = "button" if event == "click" && !window_bound
575
+ attrs[:type] = "button" if event == "click" && !window_bound && within.nil?
557
576
  attrs
558
577
  end
559
578
 
@@ -1596,7 +1615,8 @@ module Phlex
1596
1615
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
1597
1616
  # client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
1598
1617
  # CONDITIONAL, compiled by the shared compile_conditional_confirm.
1599
- def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
1618
+ def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
1619
+ within: nil)
1600
1620
  record = { "on" => event }
1601
1621
  # Issue #433: RIGHT AFTER "on" — phlex/reactive/early reads the pair
1602
1622
  # `"on":"<event>","prevent":false` off the raw attribute before connect.
@@ -1607,6 +1627,9 @@ module Phlex
1607
1627
  # this binding's :once listener — the client skips a spent once record.
1608
1628
  record["once"] = true if once
1609
1629
  record["detail"] = client_binding_detail(detail, once:) unless detail.nil?
1630
+ # Issue #442: written only when given, so a record without it is
1631
+ # byte-identical to before.
1632
+ record["within"] = within unless within.nil?
1610
1633
  case confirm
1611
1634
  when nil, false then nil
1612
1635
  when String then record["confirm"] = confirm
@@ -1617,6 +1640,29 @@ module Phlex
1617
1640
  record
1618
1641
  end
1619
1642
 
1643
+ # Issue #442: validate on_client's within: — a non-empty CSS selector,
1644
+ # on an element-bound binding that can survive a miss.
1645
+ def client_within!(event, within, window:, outside:, shortcut:, once:)
1646
+ label = "on_client(#{event.inspect})"
1647
+ unless within.is_a?(String) && !within.strip.empty?
1648
+ raise ArgumentError,
1649
+ "#{label} within: takes a non-empty CSS selector String naming the elements to delegate " \
1650
+ "from (e.g. within: \"[role=option]\"), got #{within.inspect}"
1651
+ end
1652
+ { "window:" => window, "outside:" => outside, "shortcut:" => shortcut }.each do |name, value|
1653
+ next unless value
1654
+
1655
+ raise ArgumentError,
1656
+ "#{label} within: delegates from the bound element's own subtree — it cannot be " \
1657
+ "window-bound; drop #{name}"
1658
+ end
1659
+ return unless once
1660
+
1661
+ raise ArgumentError,
1662
+ "#{label} within: cannot combine with once: — Stimulus removes a :once listener on its " \
1663
+ "first event, so a first event outside every match would leave the binding dead"
1664
+ end
1665
+
1620
1666
  # Issue #393: the one message on() and on_client() raise when given both
1621
1667
  # waits. Built only on that error path (on() is a hot path).
1622
1668
  def both_waits_message(label)