phlex-reactive 0.15.1 → 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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/README.md +82 -9
  4. data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
  5. data/app/javascript/phlex/reactive/compute.js +14 -1
  6. data/app/javascript/phlex/reactive/compute.min.js +2 -2
  7. data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
  8. data/app/javascript/phlex/reactive/core.min.js +4 -3
  9. data/app/javascript/phlex/reactive/core.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/bindings.js +66 -0
  11. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  12. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  13. data/app/javascript/phlex/reactive/features/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +31 -2
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
  21. data/app/javascript/phlex/reactive/runtime.js +476 -13
  22. data/lib/phlex/reactive/component/helpers.rb +24 -5
  23. data/lib/phlex/reactive/component/identity.rb +34 -1
  24. data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
  25. data/lib/phlex/reactive/component/media_binding.rb +103 -0
  26. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  27. data/lib/phlex/reactive/doctor.rb +20 -8
  28. data/lib/phlex/reactive/held_fields.rb +87 -0
  29. data/lib/phlex/reactive/js.rb +153 -29
  30. data/lib/phlex/reactive/streamable.rb +26 -2
  31. data/lib/phlex/reactive/test_helpers.rb +11 -2
  32. data/lib/phlex/reactive/version.rb +1 -1
  33. metadata +5 -1
@@ -200,6 +200,7 @@ export function registerReactiveStreamRender() {
200
200
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
201
201
  streamRenderRegistered = true
202
202
  document.addEventListener("turbo:before-stream-render", (event) => {
203
+ streamOwnReply(event)
203
204
  streamIntoRoots(event)
204
205
  streamModals(event)
205
206
  // The opt-in entry sees every stream (it may hold one for a module the
@@ -356,6 +357,197 @@ export function __resetReactiveModalsForTest() {
356
357
  modalsRegistered = false
357
358
  }
358
359
 
360
+ // --- keep: a morph re-applies what a kept client op did (issue #369) ---------
361
+ // A morph rewrites `class`, `hidden` and every attribute from the server
362
+ // render, so a selection made with on_client ops was lost to the next reply or
363
+ // broadcast morph. An op with keep: records its RESULT on each target — each
364
+ // class on or off, `hidden`, an attribute's value (null: absent) — and the one
365
+ // document listener below puts that back after Turbo morphs the element in
366
+ // place. The result, not the op: a toggle must not flip again on every morph.
367
+ // The latest kept op on an element and a class or attribute wins, so "clear
368
+ // all, then select one" leaves one. Classes and attributes no kept op touched
369
+ // follow the render. Weak: an element a stream replaces is a new element.
370
+ //
371
+ // One Map per element: ".name" → whether that class is on; an attribute name →
372
+ // its value, null when absent. show/hide/toggle keep the `hidden` attribute,
373
+ // so the morph puts back the visibility without running a transition again.
374
+ // (No attribute name starts with ".", so the two kinds of key never meet.)
375
+ // An entry is re-inserted on every kept op, so the Map replays in op order:
376
+ // a kept add_class after a kept set_attr("class", …) still wins.
377
+ const kept = new WeakMap()
378
+
379
+ function keepResult(el, name, args, resolveTargets) {
380
+ const state = kept.get(el) ?? new Map()
381
+ kept.set(el, state)
382
+ const put = (key, value) => {
383
+ state.delete(key)
384
+ state.set(key, value)
385
+ }
386
+ if (name.endsWith("_class")) for (const c of args.classes ?? []) put(`.${c}`, el.classList.contains(c))
387
+ else if (name.endsWith("_attr")) attrRefused(args.name) || put(args.name, el.getAttribute(args.name))
388
+ else if (name === "show" || name === "hide" || name === "toggle") {
389
+ put("hidden", el.getAttribute("hidden"))
390
+ // The expanded: trigger's aria-expanded is kept on the trigger: a document
391
+ // listener has no op scope to resolve it from later.
392
+ if (args.expanded != null) {
393
+ for (const trigger of resolveTargets({ ...args, to: args.expanded })) {
394
+ keepResult(trigger, "set_attr", { name: "aria-expanded" })
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ let keepRegistered = false
401
+
402
+ export function registerReactiveKeep() {
403
+ if (keepRegistered) return
404
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
405
+ keepRegistered = true
406
+ // turbo:morph-element fires for every element Idiomorph morphed in place,
407
+ // after its attributes were synced from the new render.
408
+ document.addEventListener("turbo:morph-element", (event) => {
409
+ const el = event.target
410
+ for (const [key, value] of kept.get(el) ?? []) {
411
+ if (key[0] === ".") el.classList.toggle(key.slice(1), value)
412
+ else if (value == null) el.removeAttribute(key)
413
+ else if (el.getAttribute(key) !== value) el.setAttribute(key, value)
414
+ }
415
+ })
416
+ }
417
+
418
+ export function __resetReactiveKeepForTest() {
419
+ keepRegistered = false
420
+ }
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
+
359
551
  // For the opt-in entry: what to do with a stream before its render — hold it
360
552
  // for a module the incoming content needs. `handle(event, effects)` gets the
361
553
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -470,18 +662,21 @@ export function registerReactiveMorphFocus() {
470
662
  const el = event.target
471
663
  const next = event.detail?.newElement // absent when Turbo asks before a removal
472
664
  if (!next || event.defaultPrevented) return
665
+ const holds = holdsTypedValue(el)
666
+ // Before the default below is overwritten (issue #370).
667
+ if (holds) noteHeld(el, next)
473
668
  if (el.hasAttribute?.("data-reactive-editable")) {
474
669
  serverTexts.set(el, editableValue(next))
475
- if (!holdsTypedValue(el)) return
670
+ if (!holds) return
476
671
  for (const { name } of [...el.attributes]) {
477
- if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
672
+ if (!next.hasAttribute(name) && name !== "contenteditable" && name !== HELD) el.removeAttribute(name)
478
673
  }
479
674
  for (const { name, value } of next.attributes) {
480
675
  if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
481
676
  }
482
677
  return event.preventDefault()
483
678
  }
484
- if (el.localName !== "input" || !holdsTypedValue(el)) return
679
+ if (el.localName !== "input" || !holds) return
485
680
  const value = next.getAttribute("value")
486
681
  if (value == null) el.removeAttribute("value")
487
682
  else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
@@ -490,8 +685,108 @@ export function registerReactiveMorphFocus() {
490
685
  // text when first focused — before anything can be typed into it.
491
686
  document.addEventListener("focusin", (event) => seedServerText(event.target))
492
687
  document.addEventListener("turbo:before-morph-attribute", (event) => {
493
- if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
688
+ const name = event.detail?.attributeName
689
+ if ((name === "value" || name === HELD) && holdsTypedValue(event.target)) event.preventDefault()
494
690
  })
691
+ // The user took theirs: the field reads its default again.
692
+ document.addEventListener("input", (event) => {
693
+ const el = event.target
694
+ if (el?.hasAttribute?.(HELD) && typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
695
+ })
696
+ }
697
+
698
+ // --- Knowing what was held (issue #370) ---
699
+ // A morph that holds a focused field (above) over a render whose value differs
700
+ // from the field's default — its last server value — means someone else changed
701
+ // the field while the user typed. The field is marked data-reactive-held (kept
702
+ // through later morphs while focused, like `value`), a bubbling reactive:held
703
+ // { name, theirs } is dispatched, and the root's next action sends the names as
704
+ // X-Reactive-Held. The root's own reply never marks (it answers exactly those
705
+ // values), and once applied the root holds nothing. A morph of the field while
706
+ // unfocused writes the server's value and drops the mark with it.
707
+ const HELD = "data-reactive-held"
708
+ const ROOT = '[data-controller~="reactive"]'
709
+ let replyRoot = null
710
+ const replying = new Map()
711
+
712
+ function typedValue(el) {
713
+ return el.hasAttribute("data-reactive-editable") ? editableValue(el) : el.value
714
+ }
715
+
716
+ function serverValue(el) {
717
+ return el.hasAttribute("data-reactive-editable") ? serverText(el) : el.defaultValue
718
+ }
719
+
720
+ function renderedValue(next) {
721
+ if (next.hasAttribute("data-reactive-editable")) return editableValue(next)
722
+ return next.localName === "textarea" ? next.textContent : (next.getAttribute("value") ?? "")
723
+ }
724
+
725
+ function noteHeld(el, next) {
726
+ const name = el.getAttribute("name")
727
+ // A checkbox's or radio's value is the option, not what the user typed.
728
+ if (!name || /^(checkbox|radio)$/.test(el.type) || replying.has(el.closest(ROOT))) return
729
+ const theirs = renderedValue(next)
730
+ if (theirs === typedValue(el)) return el.removeAttribute(HELD)
731
+ if (theirs === serverValue(el)) return
732
+ el.setAttribute(HELD, "")
733
+ el.dispatchEvent(new CustomEvent("reactive:held", { bubbles: true, detail: { name, theirs } }))
734
+ }
735
+
736
+ // The names a root's next request reports: its own marked fields (not a nested
737
+ // root's), still different from their default. As a header value: JSON with
738
+ // every non-ASCII character escaped (fetch refuses a code point above 0xFF).
739
+ function heldHeader(root) {
740
+ const names = []
741
+ for (const el of root.querySelectorAll(`[${HELD}]`)) {
742
+ if (el.closest(ROOT) !== root) continue
743
+ const name = el.getAttribute("name")
744
+ if (typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
745
+ else if (name && !names.includes(name)) names.push(name)
746
+ }
747
+ if (names.length === 0) return null
748
+ return JSON.stringify(names).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
749
+ }
750
+
751
+ function releaseHeld(root) {
752
+ for (const el of root.querySelectorAll(`[${HELD}]`)) if (el.closest(ROOT) === root) el.removeAttribute(HELD)
753
+ }
754
+
755
+ // Hand a root's own reply to Turbo: while its streams render nothing in the
756
+ // root is marked, and once they have the root holds nothing. Turbo announces
757
+ // each stream synchronously inside renderStreamMessage (streamOwnReply wraps
758
+ // its render, innermost); a reply none of whose streams rendered releases now.
759
+ // An error reply (release false) never marks either, but releases nothing: the
760
+ // server refused those values rather than answering them.
761
+ function renderOwnReply(root, html, release = true) {
762
+ const own = { root, streams: 0, release }
763
+ const previous = replyRoot
764
+ replyRoot = own
765
+ try {
766
+ window.Turbo.renderStreamMessage(html)
767
+ } finally {
768
+ replyRoot = previous
769
+ }
770
+ if (release && own.streams === 0) releaseHeld(root)
771
+ }
772
+
773
+ function streamOwnReply(event) {
774
+ const own = replyRoot
775
+ const detail = event.detail
776
+ const render = detail?.render
777
+ if (!own || typeof render !== "function") return
778
+ own.streams++
779
+ detail.render = async (el) => {
780
+ replying.set(own.root, (replying.get(own.root) || 0) + 1)
781
+ try {
782
+ return await render(el)
783
+ } finally {
784
+ const left = replying.get(own.root) - 1
785
+ if (left) replying.set(own.root, left)
786
+ else replying.delete(own.root)
787
+ if (own.release) releaseHeld(own.root)
788
+ }
789
+ }
495
790
  }
496
791
 
497
792
  export function __resetReactiveMorphFocusForTest() {
@@ -693,6 +988,8 @@ export function registerReactiveActions() {
693
988
  registerReactiveStreamRender()
694
989
  registerReactiveMorphFocus()
695
990
  registerReactiveModals()
991
+ registerReactiveKeep()
992
+ registerReactiveImageFallback()
696
993
  registerReactivePlaintext()
697
994
  registerReactiveOffline()
698
995
  registerReactiveDev()
@@ -767,7 +1064,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
767
1064
  // time; this guards a hand-built / forged ops attr from bypassing it. Refused:
768
1065
  // event handlers (on*, XSS), URL-bearing names (a javascript: navigation
769
1066
  // surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
770
- const REFUSED_ATTR_URL = new Set(["href", "src", "srcdoc", "action", "formaction", "xlink:href", "style"])
1067
+ // srcset (issue #373) too: a URL list that picks what an <img> loads.
1068
+ const REFUSED_ATTR_URL = new Set(["href", "src", "srcset", "srcdoc", "action", "formaction", "xlink:href", "style"])
771
1069
  function attrRefused(name) {
772
1070
  const lower = String(name).toLowerCase()
773
1071
  return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
@@ -964,6 +1262,19 @@ const CLIENT_OPS = Object.freeze({
964
1262
  if (el.textContent !== text) el.textContent = text
965
1263
  },
966
1264
 
1265
+ // Copy a server-rendered image (issue #373): src, srcset and alt from the
1266
+ // `from` <img>, resolved with the op's own scoping (the `submitter:`
1267
+ // precedent). The URL is read off that element, never from an argument —
1268
+ // the src refusal above stays. A selector that does not parse (a URL
1269
+ // string) is a miss, like one that matches nothing.
1270
+ copy_media: (el, args, resolveTargets) => {
1271
+ let source = null
1272
+ try {
1273
+ source = resolveTargets({ ...args, to: args.from })[0] ?? null
1274
+ } catch {}
1275
+ copyMedia(el, source)
1276
+ },
1277
+
967
1278
  // Tick or untick a whole checkbox group (issue #342) — "clear selection".
968
1279
  // The bindings feature flips the root's OWNED boxes the way its select-all
969
1280
  // header does (every box set, then input + change on each flipped one) and
@@ -1033,6 +1344,17 @@ const CLIENT_OPS = Object.freeze({
1033
1344
  // carries this op.
1034
1345
  paste_into: (el) => pasteClipboardInto(el),
1035
1346
 
1347
+ // Preview a chosen file (issue #375): paint the input's first image file
1348
+ // onto the `into` <img> through an object URL made HERE from the input's
1349
+ // own File. The op carries no URL and `args.url` is never read, so the src
1350
+ // refusal of set_attr (issue #96) stays whole. ACTOR-ONLY like paste_into
1351
+ // (BROADCAST_REFUSED_OPS server-side).
1352
+ preview_file: (el, args, resolveTargets) => previewFile(el, args, resolveTargets),
1353
+
1354
+ // A field's live value (issue #374): the paste_into write without the
1355
+ // clipboard or the focus. ACTOR-ONLY (BROADCAST_REFUSED_OPS server-side).
1356
+ set_value: (el, args) => setFieldValue(el, String(args.value ?? "")),
1357
+
1036
1358
  // Client-only drafts (issue #239): merge a flat state bag into the root's
1037
1359
  // reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
1038
1360
  // (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
@@ -1074,7 +1396,7 @@ function pasteClipboardInto(field) {
1074
1396
  .then((text) => {
1075
1397
  if (!text) return
1076
1398
  field.value = text
1077
- if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1399
+ dispatchInput(field)
1078
1400
  field.focus?.()
1079
1401
  })
1080
1402
  .catch(() => {
@@ -1083,6 +1405,109 @@ function pasteClipboardInto(field) {
1083
1405
  })
1084
1406
  }
1085
1407
 
1408
+ // --- Preview a chosen file (issue #375) ---
1409
+ // img -> the object URL preview_file last made for it. The runtime owns it:
1410
+ // never an op argument, never an attribute a caller sets. An entry lives
1411
+ // while the img is on the page AND still shows that URL; a re-pick, a clear,
1412
+ // a non-image file, the img leaving the page or a morph that strips its src
1413
+ // in place revokes it. One MutationObserver notices the last two, and only
1414
+ // while some preview is live: it then wakes on every DOM mutation of the page
1415
+ // (a live feed, a drag), and each wake is a walk over the few live previews.
1416
+ const previews = new Map()
1417
+ let previewObserver = null
1418
+
1419
+ function previewFile(input, args, resolveTargets) {
1420
+ if (input?.tagName !== "INPUT" || input.type !== "file") {
1421
+ return warnPreview(`reads an input[type=file], not <${input?.localName}>`)
1422
+ }
1423
+ const img = typeof resolveTargets === "function" ? resolveTargets({ ...args, to: args.into })[0] : null
1424
+ // Duck-typed on tagName (the isDialog precedent): only an <img> ever gets a
1425
+ // src here — never an iframe, a script, a frame or anything else.
1426
+ if (img?.tagName !== "IMG") {
1427
+ return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
1428
+ }
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/"))
1434
+ forgetPreview(img)
1435
+ if (file) {
1436
+ const url = URL.createObjectURL(file)
1437
+ previews.set(img, url)
1438
+ img.setAttribute("src", url)
1439
+ watchPreviews()
1440
+ } else img.removeAttribute("src")
1441
+ resetImage(img)
1442
+ if (args.name == null) return
1443
+ for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1444
+ }
1445
+
1446
+ function warnPreview(why) {
1447
+ console.warn(`[phlex-reactive] preview_file ${why} — skipped`)
1448
+ }
1449
+
1450
+ // Revoke the URL preview_file made for `img`, if it made one.
1451
+ function forgetPreview(img) {
1452
+ const url = previews.get(img)
1453
+ if (url === undefined) return
1454
+ previews.delete(img)
1455
+ URL.revokeObjectURL(url)
1456
+ if (previews.size === 0) {
1457
+ previewObserver?.disconnect()
1458
+ previewObserver = null
1459
+ }
1460
+ }
1461
+
1462
+ function watchPreviews() {
1463
+ if (previewObserver || typeof MutationObserver !== "function" || !document?.documentElement) return
1464
+ previewObserver = new MutationObserver(() => {
1465
+ for (const [img, url] of previews) if (!img.isConnected || img.getAttribute("src") !== url) forgetPreview(img)
1466
+ })
1467
+ previewObserver.observe(document.documentElement, {
1468
+ childList: true,
1469
+ subtree: true,
1470
+ attributes: true,
1471
+ attributeFilter: ["src"],
1472
+ })
1473
+ }
1474
+
1475
+ export function __resetReactivePreviewsForTest() {
1476
+ previewObserver?.disconnect()
1477
+ previewObserver = null
1478
+ previews.clear()
1479
+ }
1480
+
1481
+ // The set-value contract's dispatch (issue #183): a bubbling `input`, so
1482
+ // compute reducers, show bindings and on_complete run as if the user typed.
1483
+ function dispatchInput(field) {
1484
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1485
+ }
1486
+
1487
+ // The body of the set_value op (issue #374). Writes the IDL .value — never the
1488
+ // attribute, never HTML — and dispatches `input` only when the field's value
1489
+ // actually moved (compared after the write: a textarea normalizes CRLF, a
1490
+ // select ends at "" for a value no option has). No `change`, no focus. Only an
1491
+ // input, textarea or select is a target, so a contenteditable (reactive_editable's
1492
+ // to write) warns and skips like any other node. A file input takes "" alone —
1493
+ // the browser throws on anything else.
1494
+ function setFieldValue(field, value) {
1495
+ if (!/^(input|textarea|select)$/.test(field.localName) || (field.type === "file" && value !== "")) {
1496
+ console.warn("[phlex-reactive] set_value skipped: not a field it can write", field)
1497
+ return
1498
+ }
1499
+ const before = fieldState(field)
1500
+ field.value = value
1501
+ if (fieldState(field) !== before) dispatchInput(field)
1502
+ }
1503
+
1504
+ // What set_value compares: .value, or for a <select multiple> its whole
1505
+ // selection — assigning .value deselects every other option, which .value
1506
+ // (the first selected) alone would not show.
1507
+ function fieldState(field) {
1508
+ return field.type === "select-multiple" ? [...field.selectedOptions].map((o) => o.value).join("\n") : field.value
1509
+ }
1510
+
1086
1511
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
1087
1512
  // transition (issue #96). Split out so show/hide/toggle share it.
1088
1513
  function setHidden(el, hidden, args) {
@@ -1110,6 +1535,35 @@ function guardAttr(name) {
1110
1535
  return false
1111
1536
  }
1112
1537
 
1538
+ // Copy src, srcset and alt from one <img> onto another (issue #373) — the
1539
+ // copy_media op and the reactive_media binding (the bindings feature reaches it
1540
+ // through the core handle). Only an <img> target is written, so an iframe,
1541
+ // script, object, embed or frame never receives a src from here. A source that
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
1545
+ // written only when it changes, so the same URL twice does not reload. Returns
1546
+ // the alt copied, false when it cleared, or null when the target was skipped.
1547
+ function copyMedia(target, source) {
1548
+ if (target?.localName !== "img") {
1549
+ console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
1550
+ return null
1551
+ }
1552
+ const img = source?.localName === "img" ? source : null
1553
+ const src = img?.getAttribute("src")
1554
+ let changed = false
1555
+ for (const name of ["srcset", "src"]) {
1556
+ const value = src ? img.getAttribute(name) : null
1557
+ if (value == null) {
1558
+ if (target.hasAttribute(name)) changed = target.removeAttribute(name) ?? true
1559
+ } else if (target.getAttribute(name) !== value) changed = target.setAttribute(name, value) ?? true
1560
+ }
1561
+ if (changed) resetImage(target)
1562
+ const alt = img?.getAttribute("alt") ?? ""
1563
+ if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
1564
+ return img ? alt : false
1565
+ }
1566
+
1113
1567
  // The first focusable descendant of `el`, in document order — the natural
1114
1568
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
1115
1569
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -1352,7 +1806,10 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1352
1806
  }
1353
1807
  const targets = resolveTargets(args)
1354
1808
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1355
- for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
1809
+ for (const el of targets) {
1810
+ CLIENT_OPS[name](el, args, resolveTargets)
1811
+ if (args?.keep === true) keepResult(el, name, args, resolveTargets)
1812
+ }
1356
1813
  }
1357
1814
  }
1358
1815
 
@@ -1497,8 +1954,8 @@ function formNeeded(root) {
1497
1954
  // Show bindings and cross-root show targets, completion bindings, option
1498
1955
  // filtering, the bulk-selection group bindings (enable, select-all, count —
1499
1956
  // issue #319) and their cross-root targets (#343), the tag-chip input, draft
1500
- // nested rows and editable text's keys and paste (#351): a form's client-only
1501
- // bindings. The root declares one, or
1957
+ // nested rows, editable text's keys and paste (#351) and media previews
1958
+ // (#373): a form's client-only bindings. The root declares one, or
1502
1959
  // owns an element that does. (The probes the connect-time gates ran before
1503
1960
  // the split, unchanged.)
1504
1961
  function bindingsNeeded(root) {
@@ -1508,7 +1965,7 @@ function bindingsNeeded(root) {
1508
1965
  ) ||
1509
1966
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
1510
1967
  root.querySelector?.(
1511
- "[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable]",
1968
+ "[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable], [data-reactive-media]",
1512
1969
  ) != null
1513
1970
  )
1514
1971
  }
@@ -2342,6 +2799,8 @@ export default class extends Controller {
2342
2799
  plain: plaintextSupported,
2343
2800
  text: editableValue,
2344
2801
  served: serverText,
2802
+ // Copy one <img> onto another (issue #373): the reactive_media binding.
2803
+ copyMedia,
2345
2804
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2346
2805
  // (a synchronous throw is a cancel), never a rejection out.
2347
2806
  confirm: (message, context) =>
@@ -3340,6 +3799,9 @@ export default class extends Controller {
3340
3799
  presenceNeeded(this.element) &&
3341
3800
  document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
3342
3801
  if (presence) headers["X-Reactive-Presence"] = presence
3802
+ // The fields a morph held over a changed server value (issue #370).
3803
+ const held = heldHeader(this.element)
3804
+ if (held) headers["X-Reactive-Held"] = held
3343
3805
 
3344
3806
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
3345
3807
  // connection. Everything below this inner try (reading the body,
@@ -3421,7 +3883,7 @@ export default class extends Controller {
3421
3883
  if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
3422
3884
  else devtoolsFeature.recordBody(debug, errorBody, fresh)
3423
3885
  }
3424
- window.Turbo.renderStreamMessage(errorBody)
3886
+ renderOwnReply(this.element, errorBody, false)
3425
3887
  }
3426
3888
  this.#markError("http")
3427
3889
  this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
@@ -3452,8 +3914,9 @@ export default class extends Controller {
3452
3914
  // Turbo applies the <turbo-stream> ops by id. A plain replace is an
3453
3915
  // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
3454
3916
  // replace (Response.morph) or an update morphs in place, preserving the
3455
- // focused input + caret on unchanged nodes — see issue #28.
3456
- window.Turbo.renderStreamMessage(html)
3917
+ // focused input + caret on unchanged nodes — see issue #28. As the root's
3918
+ // own reply, it clears what the root held (issue #370).
3919
+ renderOwnReply(this.element, html)
3457
3920
  // Debug-only (issue #181): the morph may apply a microtask later, so check
3458
3921
  // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
3459
3922
  // null — zero cost.
@@ -15,6 +15,8 @@ module Phlex
15
15
  include Selection
16
16
  include Sortable
17
17
  include Editable
18
+ include MediaBinding
19
+ include ImageFallbackBinding
18
20
 
19
21
  # The acting client's SSE connection id during the current action (nil
20
22
  # outside an action, or when the client isn't subscribed to a stream).
@@ -31,6 +33,20 @@ module Phlex
31
33
  Phlex::Reactive.current_connection_id
32
34
  end
33
35
 
36
+ # The names of this action's posted fields that a morph held over a
37
+ # changed server value (issue #370): someone else changed the field
38
+ # while this user typed, and this tab kept the user's text. [] when
39
+ # nothing was held, and outside an action. Filtered to the action's
40
+ # declared params; a hint about what the user saw, never authorization.
41
+ #
42
+ # def save(title: nil)
43
+ # return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
44
+ # @note.update!(title:)
45
+ # end
46
+ def reactive_held_fields
47
+ Phlex::Reactive::HeldFields.current
48
+ end
49
+
34
50
  # Manually satisfy the verify_authorized guard (issue #168) for a bespoke
35
51
  # authorization check the interceptor can't see — a hand-rolled policy, a
36
52
  # feature flag, an ownership comparison that doesn't go through one of
@@ -1751,13 +1767,16 @@ module Phlex
1751
1767
 
1752
1768
  private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
1753
1769
 
1770
+ # Runs on every on() (a hot path): a key lookup per removed name, no arrays.
1754
1771
  def reject_removed_on_kwargs!(action_name, params)
1755
- removed = params.keys & REMOVED_ON_KWARGS.keys
1756
- return if removed.empty?
1772
+ return if params.empty?
1757
1773
 
1758
- key = removed.first
1759
- raise ArgumentError,
1760
- "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
1761
1780
  end
1762
1781
 
1763
1782
  # Issue #357: parse on()'s shortcut: (nil when absent). A shortcut