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.
@@ -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,68 @@ 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
+
359
422
  // For the opt-in entry: what to do with a stream before its render — hold it
360
423
  // for a module the incoming content needs. `handle(event, effects)` gets the
361
424
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -470,18 +533,21 @@ export function registerReactiveMorphFocus() {
470
533
  const el = event.target
471
534
  const next = event.detail?.newElement // absent when Turbo asks before a removal
472
535
  if (!next || event.defaultPrevented) return
536
+ const holds = holdsTypedValue(el)
537
+ // Before the default below is overwritten (issue #370).
538
+ if (holds) noteHeld(el, next)
473
539
  if (el.hasAttribute?.("data-reactive-editable")) {
474
540
  serverTexts.set(el, editableValue(next))
475
- if (!holdsTypedValue(el)) return
541
+ if (!holds) return
476
542
  for (const { name } of [...el.attributes]) {
477
- if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
543
+ if (!next.hasAttribute(name) && name !== "contenteditable" && name !== HELD) el.removeAttribute(name)
478
544
  }
479
545
  for (const { name, value } of next.attributes) {
480
546
  if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
481
547
  }
482
548
  return event.preventDefault()
483
549
  }
484
- if (el.localName !== "input" || !holdsTypedValue(el)) return
550
+ if (el.localName !== "input" || !holds) return
485
551
  const value = next.getAttribute("value")
486
552
  if (value == null) el.removeAttribute("value")
487
553
  else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
@@ -490,10 +556,110 @@ export function registerReactiveMorphFocus() {
490
556
  // text when first focused — before anything can be typed into it.
491
557
  document.addEventListener("focusin", (event) => seedServerText(event.target))
492
558
  document.addEventListener("turbo:before-morph-attribute", (event) => {
493
- if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
559
+ const name = event.detail?.attributeName
560
+ if ((name === "value" || name === HELD) && holdsTypedValue(event.target)) event.preventDefault()
561
+ })
562
+ // The user took theirs: the field reads its default again.
563
+ document.addEventListener("input", (event) => {
564
+ const el = event.target
565
+ if (el?.hasAttribute?.(HELD) && typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
494
566
  })
495
567
  }
496
568
 
569
+ // --- Knowing what was held (issue #370) ---
570
+ // A morph that holds a focused field (above) over a render whose value differs
571
+ // from the field's default — its last server value — means someone else changed
572
+ // the field while the user typed. The field is marked data-reactive-held (kept
573
+ // through later morphs while focused, like `value`), a bubbling reactive:held
574
+ // { name, theirs } is dispatched, and the root's next action sends the names as
575
+ // X-Reactive-Held. The root's own reply never marks (it answers exactly those
576
+ // values), and once applied the root holds nothing. A morph of the field while
577
+ // unfocused writes the server's value and drops the mark with it.
578
+ const HELD = "data-reactive-held"
579
+ const ROOT = '[data-controller~="reactive"]'
580
+ let replyRoot = null
581
+ const replying = new Map()
582
+
583
+ function typedValue(el) {
584
+ return el.hasAttribute("data-reactive-editable") ? editableValue(el) : el.value
585
+ }
586
+
587
+ function serverValue(el) {
588
+ return el.hasAttribute("data-reactive-editable") ? serverText(el) : el.defaultValue
589
+ }
590
+
591
+ function renderedValue(next) {
592
+ if (next.hasAttribute("data-reactive-editable")) return editableValue(next)
593
+ return next.localName === "textarea" ? next.textContent : (next.getAttribute("value") ?? "")
594
+ }
595
+
596
+ function noteHeld(el, next) {
597
+ const name = el.getAttribute("name")
598
+ // A checkbox's or radio's value is the option, not what the user typed.
599
+ if (!name || /^(checkbox|radio)$/.test(el.type) || replying.has(el.closest(ROOT))) return
600
+ const theirs = renderedValue(next)
601
+ if (theirs === typedValue(el)) return el.removeAttribute(HELD)
602
+ if (theirs === serverValue(el)) return
603
+ el.setAttribute(HELD, "")
604
+ el.dispatchEvent(new CustomEvent("reactive:held", { bubbles: true, detail: { name, theirs } }))
605
+ }
606
+
607
+ // The names a root's next request reports: its own marked fields (not a nested
608
+ // root's), still different from their default. As a header value: JSON with
609
+ // every non-ASCII character escaped (fetch refuses a code point above 0xFF).
610
+ function heldHeader(root) {
611
+ const names = []
612
+ for (const el of root.querySelectorAll(`[${HELD}]`)) {
613
+ if (el.closest(ROOT) !== root) continue
614
+ const name = el.getAttribute("name")
615
+ if (typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
616
+ else if (name && !names.includes(name)) names.push(name)
617
+ }
618
+ if (names.length === 0) return null
619
+ return JSON.stringify(names).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
620
+ }
621
+
622
+ function releaseHeld(root) {
623
+ for (const el of root.querySelectorAll(`[${HELD}]`)) if (el.closest(ROOT) === root) el.removeAttribute(HELD)
624
+ }
625
+
626
+ // Hand a root's own reply to Turbo: while its streams render nothing in the
627
+ // root is marked, and once they have the root holds nothing. Turbo announces
628
+ // each stream synchronously inside renderStreamMessage (streamOwnReply wraps
629
+ // its render, innermost); a reply none of whose streams rendered releases now.
630
+ // An error reply (release false) never marks either, but releases nothing: the
631
+ // server refused those values rather than answering them.
632
+ function renderOwnReply(root, html, release = true) {
633
+ const own = { root, streams: 0, release }
634
+ const previous = replyRoot
635
+ replyRoot = own
636
+ try {
637
+ window.Turbo.renderStreamMessage(html)
638
+ } finally {
639
+ replyRoot = previous
640
+ }
641
+ if (release && own.streams === 0) releaseHeld(root)
642
+ }
643
+
644
+ function streamOwnReply(event) {
645
+ const own = replyRoot
646
+ const detail = event.detail
647
+ const render = detail?.render
648
+ if (!own || typeof render !== "function") return
649
+ own.streams++
650
+ detail.render = async (el) => {
651
+ replying.set(own.root, (replying.get(own.root) || 0) + 1)
652
+ try {
653
+ return await render(el)
654
+ } finally {
655
+ const left = replying.get(own.root) - 1
656
+ if (left) replying.set(own.root, left)
657
+ else replying.delete(own.root)
658
+ if (own.release) releaseHeld(own.root)
659
+ }
660
+ }
661
+ }
662
+
497
663
  export function __resetReactiveMorphFocusForTest() {
498
664
  morphFocusRegistered = false
499
665
  }
@@ -693,6 +859,7 @@ export function registerReactiveActions() {
693
859
  registerReactiveStreamRender()
694
860
  registerReactiveMorphFocus()
695
861
  registerReactiveModals()
862
+ registerReactiveKeep()
696
863
  registerReactivePlaintext()
697
864
  registerReactiveOffline()
698
865
  registerReactiveDev()
@@ -767,7 +934,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
767
934
  // time; this guards a hand-built / forged ops attr from bypassing it. Refused:
768
935
  // event handlers (on*, XSS), URL-bearing names (a javascript: navigation
769
936
  // 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"])
937
+ // srcset (issue #373) too: a URL list that picks what an <img> loads.
938
+ const REFUSED_ATTR_URL = new Set(["href", "src", "srcset", "srcdoc", "action", "formaction", "xlink:href", "style"])
771
939
  function attrRefused(name) {
772
940
  const lower = String(name).toLowerCase()
773
941
  return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
@@ -964,6 +1132,19 @@ const CLIENT_OPS = Object.freeze({
964
1132
  if (el.textContent !== text) el.textContent = text
965
1133
  },
966
1134
 
1135
+ // Copy a server-rendered image (issue #373): src, srcset and alt from the
1136
+ // `from` <img>, resolved with the op's own scoping (the `submitter:`
1137
+ // precedent). The URL is read off that element, never from an argument —
1138
+ // the src refusal above stays. A selector that does not parse (a URL
1139
+ // string) is a miss, like one that matches nothing.
1140
+ copy_media: (el, args, resolveTargets) => {
1141
+ let source = null
1142
+ try {
1143
+ source = resolveTargets({ ...args, to: args.from })[0] ?? null
1144
+ } catch {}
1145
+ copyMedia(el, source)
1146
+ },
1147
+
967
1148
  // Tick or untick a whole checkbox group (issue #342) — "clear selection".
968
1149
  // The bindings feature flips the root's OWNED boxes the way its select-all
969
1150
  // header does (every box set, then input + change on each flipped one) and
@@ -1033,6 +1214,17 @@ const CLIENT_OPS = Object.freeze({
1033
1214
  // carries this op.
1034
1215
  paste_into: (el) => pasteClipboardInto(el),
1035
1216
 
1217
+ // Preview a chosen file (issue #375): paint the input's first image file
1218
+ // onto the `into` <img> through an object URL made HERE from the input's
1219
+ // own File. The op carries no URL and `args.url` is never read, so the src
1220
+ // refusal of set_attr (issue #96) stays whole. ACTOR-ONLY like paste_into
1221
+ // (BROADCAST_REFUSED_OPS server-side).
1222
+ preview_file: (el, args, resolveTargets) => previewFile(el, args, resolveTargets),
1223
+
1224
+ // A field's live value (issue #374): the paste_into write without the
1225
+ // clipboard or the focus. ACTOR-ONLY (BROADCAST_REFUSED_OPS server-side).
1226
+ set_value: (el, args) => setFieldValue(el, String(args.value ?? "")),
1227
+
1036
1228
  // Client-only drafts (issue #239): merge a flat state bag into the root's
1037
1229
  // reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
1038
1230
  // (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
@@ -1074,7 +1266,7 @@ function pasteClipboardInto(field) {
1074
1266
  .then((text) => {
1075
1267
  if (!text) return
1076
1268
  field.value = text
1077
- if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1269
+ dispatchInput(field)
1078
1270
  field.focus?.()
1079
1271
  })
1080
1272
  .catch(() => {
@@ -1083,6 +1275,104 @@ function pasteClipboardInto(field) {
1083
1275
  })
1084
1276
  }
1085
1277
 
1278
+ // --- Preview a chosen file (issue #375) ---
1279
+ // img -> the object URL preview_file last made for it. The runtime owns it:
1280
+ // never an op argument, never an attribute a caller sets. An entry lives
1281
+ // while the img is on the page AND still shows that URL; a re-pick, a clear,
1282
+ // a non-image file, the img leaving the page or a morph that strips its src
1283
+ // in place revokes it. One MutationObserver notices the last two, and only
1284
+ // while some preview is live: it then wakes on every DOM mutation of the page
1285
+ // (a live feed, a drag), and each wake is a walk over the few live previews.
1286
+ const previews = new Map()
1287
+ let previewObserver = null
1288
+
1289
+ function previewFile(input, args, resolveTargets) {
1290
+ if (input?.tagName !== "INPUT" || input.type !== "file") {
1291
+ return warnPreview(`reads an input[type=file], not <${input?.localName}>`)
1292
+ }
1293
+ const img = typeof resolveTargets === "function" ? resolveTargets({ ...args, to: args.into })[0] : null
1294
+ // Duck-typed on tagName (the isDialog precedent): only an <img> ever gets a
1295
+ // src here — never an iframe, a script, a frame or anything else.
1296
+ if (img?.tagName !== "IMG") {
1297
+ return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
1298
+ }
1299
+ const file = [...(input.files ?? [])].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1300
+ forgetPreview(img)
1301
+ if (file) {
1302
+ const url = URL.createObjectURL(file)
1303
+ previews.set(img, url)
1304
+ img.setAttribute("src", url)
1305
+ watchPreviews()
1306
+ } else img.removeAttribute("src")
1307
+ if (args.name == null) return
1308
+ for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1309
+ }
1310
+
1311
+ function warnPreview(why) {
1312
+ console.warn(`[phlex-reactive] preview_file ${why} — skipped`)
1313
+ }
1314
+
1315
+ // Revoke the URL preview_file made for `img`, if it made one.
1316
+ function forgetPreview(img) {
1317
+ const url = previews.get(img)
1318
+ if (url === undefined) return
1319
+ previews.delete(img)
1320
+ URL.revokeObjectURL(url)
1321
+ if (previews.size === 0) {
1322
+ previewObserver?.disconnect()
1323
+ previewObserver = null
1324
+ }
1325
+ }
1326
+
1327
+ function watchPreviews() {
1328
+ if (previewObserver || typeof MutationObserver !== "function" || !document?.documentElement) return
1329
+ previewObserver = new MutationObserver(() => {
1330
+ for (const [img, url] of previews) if (!img.isConnected || img.getAttribute("src") !== url) forgetPreview(img)
1331
+ })
1332
+ previewObserver.observe(document.documentElement, {
1333
+ childList: true,
1334
+ subtree: true,
1335
+ attributes: true,
1336
+ attributeFilter: ["src"],
1337
+ })
1338
+ }
1339
+
1340
+ export function __resetReactivePreviewsForTest() {
1341
+ previewObserver?.disconnect()
1342
+ previewObserver = null
1343
+ previews.clear()
1344
+ }
1345
+
1346
+ // The set-value contract's dispatch (issue #183): a bubbling `input`, so
1347
+ // compute reducers, show bindings and on_complete run as if the user typed.
1348
+ function dispatchInput(field) {
1349
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1350
+ }
1351
+
1352
+ // The body of the set_value op (issue #374). Writes the IDL .value — never the
1353
+ // attribute, never HTML — and dispatches `input` only when the field's value
1354
+ // actually moved (compared after the write: a textarea normalizes CRLF, a
1355
+ // select ends at "" for a value no option has). No `change`, no focus. Only an
1356
+ // input, textarea or select is a target, so a contenteditable (reactive_editable's
1357
+ // to write) warns and skips like any other node. A file input takes "" alone —
1358
+ // the browser throws on anything else.
1359
+ function setFieldValue(field, value) {
1360
+ if (!/^(input|textarea|select)$/.test(field.localName) || (field.type === "file" && value !== "")) {
1361
+ console.warn("[phlex-reactive] set_value skipped: not a field it can write", field)
1362
+ return
1363
+ }
1364
+ const before = fieldState(field)
1365
+ field.value = value
1366
+ if (fieldState(field) !== before) dispatchInput(field)
1367
+ }
1368
+
1369
+ // What set_value compares: .value, or for a <select multiple> its whole
1370
+ // selection — assigning .value deselects every other option, which .value
1371
+ // (the first selected) alone would not show.
1372
+ function fieldState(field) {
1373
+ return field.type === "select-multiple" ? [...field.selectedOptions].map((o) => o.value).join("\n") : field.value
1374
+ }
1375
+
1086
1376
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
1087
1377
  // transition (issue #96). Split out so show/hide/toggle share it.
1088
1378
  function setHidden(el, hidden, args) {
@@ -1110,6 +1400,31 @@ function guardAttr(name) {
1110
1400
  return false
1111
1401
  }
1112
1402
 
1403
+ // Copy src, srcset and alt from one <img> onto another (issue #373) — the
1404
+ // copy_media op and the reactive_media binding (the bindings feature reaches it
1405
+ // through the core handle). Only an <img> target is written, so an iframe,
1406
+ // 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
1408
+ // written only when it changes, so the same URL twice does not reload. Returns
1409
+ // the alt copied, false when it cleared, or null when the target was skipped.
1410
+ function copyMedia(target, source) {
1411
+ if (target?.localName !== "img") {
1412
+ console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
1413
+ return null
1414
+ }
1415
+ const src = source?.localName === "img" ? source.getAttribute("src") : null
1416
+ const pick = (name) => (src ? source.getAttribute(name) : null)
1417
+ for (const name of ["srcset", "src"]) {
1418
+ const value = pick(name)
1419
+ if (value == null) {
1420
+ if (target.hasAttribute(name)) target.removeAttribute(name)
1421
+ } else if (target.getAttribute(name) !== value) target.setAttribute(name, value)
1422
+ }
1423
+ const alt = pick("alt") ?? ""
1424
+ if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
1425
+ return src ? alt : false
1426
+ }
1427
+
1113
1428
  // The first focusable descendant of `el`, in document order — the natural
1114
1429
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
1115
1430
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -1352,7 +1667,10 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1352
1667
  }
1353
1668
  const targets = resolveTargets(args)
1354
1669
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1355
- for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
1670
+ for (const el of targets) {
1671
+ CLIENT_OPS[name](el, args, resolveTargets)
1672
+ if (args?.keep === true) keepResult(el, name, args, resolveTargets)
1673
+ }
1356
1674
  }
1357
1675
  }
1358
1676
 
@@ -1497,8 +1815,8 @@ function formNeeded(root) {
1497
1815
  // Show bindings and cross-root show targets, completion bindings, option
1498
1816
  // filtering, the bulk-selection group bindings (enable, select-all, count —
1499
1817
  // 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
1818
+ // nested rows, editable text's keys and paste (#351) and media previews
1819
+ // (#373): a form's client-only bindings. The root declares one, or
1502
1820
  // owns an element that does. (The probes the connect-time gates ran before
1503
1821
  // the split, unchanged.)
1504
1822
  function bindingsNeeded(root) {
@@ -1508,7 +1826,7 @@ function bindingsNeeded(root) {
1508
1826
  ) ||
1509
1827
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
1510
1828
  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]",
1829
+ "[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
1830
  ) != null
1513
1831
  )
1514
1832
  }
@@ -2342,6 +2660,8 @@ export default class extends Controller {
2342
2660
  plain: plaintextSupported,
2343
2661
  text: editableValue,
2344
2662
  served: serverText,
2663
+ // Copy one <img> onto another (issue #373): the reactive_media binding.
2664
+ copyMedia,
2345
2665
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2346
2666
  // (a synchronous throw is a cancel), never a rejection out.
2347
2667
  confirm: (message, context) =>
@@ -3340,6 +3660,9 @@ export default class extends Controller {
3340
3660
  presenceNeeded(this.element) &&
3341
3661
  document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
3342
3662
  if (presence) headers["X-Reactive-Presence"] = presence
3663
+ // The fields a morph held over a changed server value (issue #370).
3664
+ const held = heldHeader(this.element)
3665
+ if (held) headers["X-Reactive-Held"] = held
3343
3666
 
3344
3667
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
3345
3668
  // connection. Everything below this inner try (reading the body,
@@ -3421,7 +3744,7 @@ export default class extends Controller {
3421
3744
  if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
3422
3745
  else devtoolsFeature.recordBody(debug, errorBody, fresh)
3423
3746
  }
3424
- window.Turbo.renderStreamMessage(errorBody)
3747
+ renderOwnReply(this.element, errorBody, false)
3425
3748
  }
3426
3749
  this.#markError("http")
3427
3750
  this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
@@ -3452,8 +3775,9 @@ export default class extends Controller {
3452
3775
  // Turbo applies the <turbo-stream> ops by id. A plain replace is an
3453
3776
  // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
3454
3777
  // 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)
3778
+ // focused input + caret on unchanged nodes — see issue #28. As the root's
3779
+ // own reply, it clears what the root held (issue #370).
3780
+ renderOwnReply(this.element, html)
3457
3781
  // Debug-only (issue #181): the morph may apply a microtask later, so check
3458
3782
  // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
3459
3783
  // null — zero cost.
@@ -15,6 +15,7 @@ module Phlex
15
15
  include Selection
16
16
  include Sortable
17
17
  include Editable
18
+ include MediaBinding
18
19
 
19
20
  # The acting client's SSE connection id during the current action (nil
20
21
  # outside an action, or when the client isn't subscribed to a stream).
@@ -31,6 +32,20 @@ module Phlex
31
32
  Phlex::Reactive.current_connection_id
32
33
  end
33
34
 
35
+ # The names of this action's posted fields that a morph held over a
36
+ # changed server value (issue #370): someone else changed the field
37
+ # while this user typed, and this tab kept the user's text. [] when
38
+ # nothing was held, and outside an action. Filtered to the action's
39
+ # declared params; a hint about what the user saw, never authorization.
40
+ #
41
+ # def save(title: nil)
42
+ # return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
43
+ # @note.update!(title:)
44
+ # end
45
+ def reactive_held_fields
46
+ Phlex::Reactive::HeldFields.current
47
+ end
48
+
34
49
  # Manually satisfy the verify_authorized guard (issue #168) for a bespoke
35
50
  # authorization check the interceptor can't see — a hand-rolled policy, a
36
51
  # feature flag, an ownership comparison that doesn't go through one of
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # A preview image that follows a field (issue #373), with no URL in the
7
+ # page's ops or attributes: the server renders every candidate <img>
8
+ # (hidden), and the binding copies the one whose key matches.
9
+ #
10
+ # input(name: "code")
11
+ # img(id: "logo", alt: "", **reactive_media(
12
+ # from: :code, take: 3,
13
+ # pool: { "007" => "#logo-007", "011" => "#logo-011" },
14
+ # label: "#caption", blank_label: "Unknown"
15
+ # ))
16
+ # img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
17
+ #
18
+ # The field is read the way reactive_show reads one (owned fields, a
19
+ # radio group's checked value, reactive_scope applied). `take:` keeps the
20
+ # first n characters as the key; omit it to use the whole value. A pool
21
+ # value is a CSS SELECTOR for an <img> inside the root, never a URL: the
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,
26
+ # no round trip.
27
+ #
28
+ # Named MediaBinding, not Media: this module sits in every reactive
29
+ # component's ancestry, and a bare `Media` would shadow a host app's kit
30
+ # component of that name (issue #233).
31
+ module MediaBinding
32
+ def reactive_media(from:, pool: nil, take: nil, label: nil, blank_label: nil, **attrs)
33
+ wire = { "field" => media_field!(from) }
34
+ wire["take"] = media_take!(take) unless take.nil?
35
+ wire["pool"] = media_pool!(pool)
36
+ wire["label"] = media_selector!(:label, label) unless label.nil?
37
+ wire["blank"] = blank_label.to_s unless blank_label.nil?
38
+ mix({ data: { reactive_media: wire.to_json } }, attrs)
39
+ end
40
+
41
+ private
42
+
43
+ def media_field!(from)
44
+ name = from.to_s if from.is_a?(Symbol) || from.is_a?(String)
45
+ return name unless name.nil? || name.strip.empty?
46
+
47
+ raise ArgumentError, "reactive_media from: needs the field name to watch (e.g. from: :code), got #{from.inspect}"
48
+ end
49
+
50
+ def media_take!(take)
51
+ return take if take.is_a?(Integer) && take.positive?
52
+
53
+ raise ArgumentError, "reactive_media take: must be a positive Integer (or omitted for the whole value), " \
54
+ "got #{take.inspect}"
55
+ end
56
+
57
+ def media_pool!(pool)
58
+ if pool.nil?
59
+ raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
60
+ "(e.g. pool: { \"007\" => \"#logo-007\" })"
61
+ end
62
+ unless pool.is_a?(Hash) && !pool.empty?
63
+ raise ArgumentError,
64
+ "reactive_media pool: must be a non-empty Hash of key => <img> selector, got #{pool.inspect}"
65
+ end
66
+
67
+ pool.to_h { |key, selector| [key.to_s, media_selector!(:pool, selector)] }
68
+ end
69
+
70
+ # A pool value or label: is a selector, never a URL — the client only
71
+ # ever passes it to querySelector.
72
+ def media_selector!(kind, selector)
73
+ if selector == JS::ROOT_SENTINEL
74
+ raise ArgumentError,
75
+ "reactive_media #{kind}: #{selector} names the whole root — pass the selector of an element inside it"
76
+ end
77
+ return selector if selector.is_a?(String) && !selector.strip.empty?
78
+
79
+ raise ArgumentError,
80
+ "reactive_media #{kind}: values are CSS selectors (e.g. \"#logo-007\"), got #{selector.inspect}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end