phlex-reactive 0.12.3 → 0.12.5

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.
@@ -103,10 +103,22 @@ export function registerReactiveJs() {
103
103
  const list = parseOps(this.getAttribute("data-reactive-ops"))
104
104
  if (!list.length) return
105
105
  const targetId = this.getAttribute("target")
106
+ // Issue #237: the server stamps the verbose gate on the stream element
107
+ // itself (verbose_errors), so document-scoped ops are diagnosable too.
108
+ const verbose = this.getAttribute("data-reactive-verbose") === "true"
106
109
  // With a target: scope to that element (missing → no-op). Without: document.
107
110
  const root = targetId ? document.getElementById(targetId) : null
108
- if (targetId && !root) return
109
- applyOps(list, (args) => streamOpTargets(args, root))
111
+ if (targetId && !root) {
112
+ if (verbose && !zeroTargetAlreadyWarned(`missing-root|#${targetId}`)) {
113
+ console.warn(`[phlex-reactive] reactive:js stream target root #${targetId} is not in the DOM — its ops were dropped`)
114
+ }
115
+ return
116
+ }
117
+ applyOps(
118
+ list,
119
+ (args) => streamOpTargets(args, root),
120
+ verbose ? (name, args) => diagnoseStreamZeroTargets(name, args, root) : undefined,
121
+ )
110
122
  }
111
123
  }
112
124
 
@@ -1047,9 +1059,11 @@ function runTransition(el, transition, flip) {
1047
1059
  // Phlex::Reactive::JS's vocabulary; an op name not in this map is
1048
1060
  // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
1049
1061
  // ops attr must never break the page). Each op is a pure, local DOM mutation:
1050
- // nothing is read back, nothing is sent anywhere. Frozen so nothing can be
1051
- // registered into it at runtime — extending the vocabulary is a gem change,
1052
- // not an app hook.
1062
+ // nothing is sent anywhere, and nothing is read back — with ONE deliberate
1063
+ // exception, paste_into (issue #228), which reads the clipboard behind the
1064
+ // browser's own gesture + permission gates and still only writes locally.
1065
+ // Frozen so nothing can be registered into it at runtime — extending the
1066
+ // vocabulary is a gem change, not an app hook.
1053
1067
  const CLIENT_OPS = Object.freeze({
1054
1068
  show: (el, args) => setHidden(el, false, args),
1055
1069
  hide: (el, args) => setHidden(el, true, args),
@@ -1099,6 +1113,24 @@ const CLIENT_OPS = Object.freeze({
1099
1113
  // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the
1100
1114
  // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).
1101
1115
  submit: (el) => submitFormFor(el)?.requestSubmit?.(),
1116
+
1117
+ // Clipboard-source paste (issue #228): on a user gesture, read
1118
+ // navigator.clipboard.readText() and feed the text into the target field
1119
+ // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does.
1120
+ // The ONE op that reads a browser API and is async: fire-and-forget, so
1121
+ // applyOps stays sync and chain siblings never wait (the runTransition
1122
+ // posture). A rejected/dismissed read, empty text, or a missing API is a
1123
+ // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit:
1124
+ // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) —
1125
+ // and that server gate is the REAL one. The browser only partially backs it
1126
+ // up: Safari gates every read on a fresh gesture and Firefox shows its
1127
+ // paste picker per read, but Chromium's clipboard-read is a PERSISTENT
1128
+ // per-origin permission — once granted (the legit paste button itself
1129
+ // induces that), readText() succeeds with no gesture. No client-side
1130
+ // refusal is possible here: the reactive:js interpreter cannot distinguish
1131
+ // an actor reply's stream from a broadcast's, and reply.js legitimately
1132
+ // carries this op.
1133
+ paste_into: (el) => pasteClipboardInto(el),
1102
1134
  })
1103
1135
 
1104
1136
  // The form a submit op commits (issue #226), in order: the target itself when
@@ -1111,6 +1143,32 @@ function submitFormFor(el) {
1111
1143
  return el?.form ?? el?.closest?.("form") ?? null
1112
1144
  }
1113
1145
 
1146
+ // Read the clipboard into a field (issue #228) — the body of the paste_into
1147
+ // op. The write mirrors a native paste: set .value, dispatch a bubbling
1148
+ // `input` event (the set-value + dispatch contract, issue #183 — compute
1149
+ // reducers, show bindings, and on_complete all run exactly as if the user
1150
+ // had typed), then focus (the caret lands where the user continues typing on
1151
+ // a partial paste). Availability-guarded: insecure contexts and some
1152
+ // webviews have no navigator.clipboard — the connect()-time gate hides
1153
+ // marked triggers there, so this guard is belt-and-braces. Empty text is a
1154
+ // no-op: "paste nothing" must not clear a half-typed field.
1155
+ function pasteClipboardInto(field) {
1156
+ const clipboard = globalThis.navigator?.clipboard
1157
+ if (typeof clipboard?.readText !== "function") return
1158
+ clipboard
1159
+ .readText()
1160
+ .then((text) => {
1161
+ if (!text) return
1162
+ field.value = text
1163
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1164
+ field.focus?.()
1165
+ })
1166
+ .catch(() => {
1167
+ // Permission denied or the prompt dismissed — the browser's own UX said
1168
+ // no. The issue-#228 contract: a silent no-op, never an error.
1169
+ })
1170
+ }
1171
+
1114
1172
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
1115
1173
  // transition (issue #96). Split out so show/hide/toggle share it.
1116
1174
  function setHidden(el, hidden, args) {
@@ -1445,7 +1503,7 @@ function computeOpsList(raw) {
1445
1503
  // chain still applies — client-side default-deny, one bad op never takes down
1446
1504
  // its siblings. Object.hasOwn (not a bare read) so inherited Object members
1447
1505
  // ("constructor") can't masquerade as ops.
1448
- function applyOps(list, resolveTargets) {
1506
+ function applyOps(list, resolveTargets, onZeroTargets) {
1449
1507
  for (const entry of list) {
1450
1508
  if (!Array.isArray(entry)) continue
1451
1509
  const [name, args = {}] = entry
@@ -1453,8 +1511,68 @@ function applyOps(list, resolveTargets) {
1453
1511
  console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1454
1512
  continue
1455
1513
  }
1456
- for (const el of resolveTargets(args)) CLIENT_OPS[name](el, args)
1514
+ const targets = resolveTargets(args)
1515
+ if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1516
+ for (const el of targets) CLIENT_OPS[name](el, args)
1517
+ }
1518
+ }
1519
+
1520
+ // --- zero-target diagnostics (issue #237) -----------------------------------
1521
+ // An op resolving ZERO targets is indistinguishable from a working no-op, and
1522
+ // the documented scoping traps (nested-root ownership filter, root-self
1523
+ // selector, stream default scope) all present exactly that way. Under the
1524
+ // verbose gate (data-reactive-verbose, stamped when Phlex::Reactive
1525
+ // .verbose_errors is on — dev/test by default — or the debug attr) warn ONCE
1526
+ // per unique (label, selector, scope) with a targeted hint when the element
1527
+ // EXISTS but sits outside the op's scope. Everything below runs only after a
1528
+ // zero-match with the gate on; production (no attr) pays one boolean per empty
1529
+ // resolution and never probes the DOM.
1530
+ //
1531
+ // Dedupe is keyed per document (page lifetime): a WeakMap entry per document
1532
+ // means a fresh page — or a fresh unit-harness stub — starts clean, and the
1533
+ // per-keystroke reducer ($ops) path can never flood the console.
1534
+ const zeroTargetWarnSets = new WeakMap()
1535
+
1536
+ function zeroTargetAlreadyWarned(key) {
1537
+ const doc = globalThis.document
1538
+ if (!doc) return true
1539
+ let seen = zeroTargetWarnSets.get(doc)
1540
+ if (!seen) {
1541
+ seen = new Set()
1542
+ zeroTargetWarnSets.set(doc, seen)
1543
+ }
1544
+ if (seen.has(key)) return true
1545
+ seen.add(key)
1546
+ return false
1547
+ }
1548
+
1549
+ // Guarded probes: unit harnesses stub partial documents/roots, and an exotic
1550
+ // selector could throw — a diagnostic must never break the op pipeline.
1551
+ function countMatches(node, selector) {
1552
+ try {
1553
+ return node?.querySelectorAll?.(selector)?.length ?? 0
1554
+ } catch {
1555
+ return 0
1556
+ }
1557
+ }
1558
+
1559
+ function emitZeroTargetWarn(label, to, scope, hint) {
1560
+ if (zeroTargetAlreadyWarned(`${label}|${to}|${scope}`)) return
1561
+ console.warn(`[phlex-reactive] ${label} matched zero targets for selector "${to}" (${scope})${hint}`)
1562
+ }
1563
+
1564
+ // The stream-path diagnoser (reactive:js). No ownership filter exists here, so
1565
+ // the only trap is the target-root scope: the selector matches document-wide
1566
+ // but the op was scoped to the stream's target root.
1567
+ function diagnoseStreamZeroTargets(name, args, root) {
1568
+ const to = args.to
1569
+ if (typeof to !== "string" || to === "" || to === "@root") return
1570
+ let hint = ""
1571
+ if (root && !args.global) {
1572
+ const n = countMatches(globalThis.document, to)
1573
+ if (n > 0) hint = ` — it matches ${n} element(s) outside the stream's target root; use global: true`
1457
1574
  }
1575
+ emitZeroTargetWarn(`client op "${name}"`, to, root ? `scoped to #${root.id || "?"}` : "document-scoped", hint)
1458
1576
  }
1459
1577
 
1460
1578
  // Resolve a reactive:js op's targets against its `target` root (issue #97).
@@ -1554,6 +1672,9 @@ export default class extends Controller {
1554
1672
  // Lazy initial mount (issue #165): the bound re-probe attached to
1555
1673
  // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
1556
1674
  #boundProbeLazyDefer
1675
+ // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1676
+ // held for teardown.
1677
+ #boundSyncClipboard
1557
1678
 
1558
1679
  // Mark that a reactive controller actually connected, so the registration
1559
1680
  // guard above knows the controller was registered (issue #26 part 2).
@@ -1735,6 +1856,24 @@ export default class extends Controller {
1735
1856
  this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute)
1736
1857
  this.recompute()
1737
1858
  }
1859
+
1860
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
1861
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard),
1862
+ // so every other component pays a single probe (the show/filter/tags gate
1863
+ // precedent). The Async Clipboard API is absent in insecure contexts and
1864
+ // some webviews; a paste button that can never work must not show. The
1865
+ // gate OWNS a marked trigger's `hidden` flag: author the trigger `hidden`
1866
+ // and this pass reveals it where the API exists (a dead button never
1867
+ // paints); turbo:morph-element re-syncs because a morph rewrites the
1868
+ // trigger back to its authored hidden state. Like every sibling gate the
1869
+ // decision is made ONCE at connect — render the paste trigger
1870
+ // unconditionally: a trigger first INTRODUCED by a later morph stays
1871
+ // ungated (hidden) until a full replace re-connects the controller.
1872
+ if (this.#clipboardGateEnabled()) {
1873
+ this.#boundSyncClipboard = () => this.#syncClipboardTriggers()
1874
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
1875
+ this.#syncClipboardTriggers()
1876
+ }
1738
1877
  }
1739
1878
 
1740
1879
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -1765,6 +1904,7 @@ export default class extends Controller {
1765
1904
  this.#teardownTagsSync()
1766
1905
  this.#teardownNestedJsonSync()
1767
1906
  this.#teardownComputeSeed()
1907
+ this.#teardownClipboardGate()
1768
1908
  if (this.#boundProbeLazyDefer) {
1769
1909
  this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1770
1910
  }
@@ -2136,7 +2276,11 @@ export default class extends Controller {
2136
2276
  const fire = signature !== null && signature !== this.#computeOpsSignature && eventDriven
2137
2277
  this.#computeOpsSignature = signature
2138
2278
  if (!fire) return
2139
- applyOps(list, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
2279
+ applyOps(
2280
+ list,
2281
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
2282
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
2283
+ )
2140
2284
  }
2141
2285
 
2142
2286
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -3551,6 +3695,40 @@ export default class extends Controller {
3551
3695
  return !!this.element.getAttribute?.("data-reactive-on-complete")
3552
3696
  }
3553
3697
 
3698
+ // Whether this root owns a clipboard-marked paste trigger (issue #228) —
3699
+ // the connect() gate. The ROOT itself counts (a button-only component that
3700
+ // mixes on_client(paste_into) onto reactive_root — the #dirtyTrackingEnabled
3701
+ // root-then-descendants precedent), then one scoped query; a NESTED root's
3702
+ // triggers are its own controller's to gate (issue #15 ownership).
3703
+ #clipboardGateEnabled() {
3704
+ if (this.element.getAttribute?.("data-reactive-clipboard")) return true
3705
+ const nodes = this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []
3706
+ for (const el of nodes) if (this.#ownsField(el)) return true
3707
+ return false
3708
+ }
3709
+
3710
+ // Set every owned paste trigger's `hidden` from clipboard availability
3711
+ // (issue #228): available → revealed (the authored `hidden` was only the
3712
+ // no-dead-button first paint), missing → hidden (insecure context /
3713
+ // webview). The gate owns the flag on MARKED elements only — nothing else
3714
+ // is ever touched. A marked ROOT is gated too: when the component IS the
3715
+ // paste button, hiding the root is exactly "the dead button never shows".
3716
+ #syncClipboardTriggers() {
3717
+ const available = typeof globalThis.navigator?.clipboard?.readText === "function"
3718
+ if (this.element.getAttribute?.("data-reactive-clipboard")) this.element.hidden = !available
3719
+ for (const el of this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []) {
3720
+ if (this.#ownsField(el)) el.hidden = !available
3721
+ }
3722
+ }
3723
+
3724
+ // Remove the clipboard gate's morph listener on disconnect, so a stray
3725
+ // morph event after a Turbo navigation never re-syncs a detached root.
3726
+ #teardownClipboardGate() {
3727
+ if (!this.#boundSyncClipboard) return
3728
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
3729
+ this.#boundSyncClipboard = undefined
3730
+ }
3731
+
3554
3732
  // Parse-and-memoize the completion bindings, keyed on the RAW attr string:
3555
3733
  // a morph that rewrote the payload re-parses and RESETS the latches (the
3556
3734
  // morph listener's own arm pass then re-arms without firing). A removed
@@ -3589,7 +3767,13 @@ export default class extends Controller {
3589
3767
  if (matches === null) return
3590
3768
  const fire = matches && !this.#onCompleteStates[i] && Boolean(event)
3591
3769
  this.#onCompleteStates[i] = matches
3592
- if (fire) applyOps(binding.ops, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
3770
+ if (fire) {
3771
+ applyOps(
3772
+ binding.ops,
3773
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
3774
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
3775
+ )
3776
+ }
3593
3777
  })
3594
3778
  }
3595
3779
 
@@ -4131,19 +4315,39 @@ export default class extends Controller {
4131
4315
  fd.append("token", token)
4132
4316
  fd.append("act", action)
4133
4317
  for (const [key, value] of Object.entries(params)) {
4134
- this.#appendField(fd, `params[${key}]`, value)
4318
+ this.#appendField(fd, this.#wireKey(key), value)
4135
4319
  }
4136
4320
  const multiNames = this.#multiFileNames(files)
4137
4321
  for (const { name, file, multiple } of files) {
4138
4322
  // params[name][] when the input is `multiple` (array shape even for one
4139
4323
  // file) OR the name repeats across inputs; otherwise a lone scalar file.
4140
4324
  const asArray = multiple || multiNames.has(name)
4141
- const key = asArray ? `params[${name}][]` : `params[${name}]`
4325
+ const key = asArray ? `${this.#wireKey(name)}[]` : this.#wireKey(name)
4142
4326
  fd.append(key, file, file.name)
4143
4327
  }
4144
4328
  return fd
4145
4329
  }
4146
4330
 
4331
+ // The multipart wire key for a collected field/file name: params[...] with
4332
+ // the name's OWN brackets expanded into nesting segments (issue #231). The
4333
+ // old verbatim wrap of a Rails-bracketed name — params[blog_post[summary]] —
4334
+ // is unparseable by Rack: a scalar arrived as {"blog_post[summary" => {"]" =>
4335
+ // value}} (data corruption written through `update!` with a 200) and a file
4336
+ // never reached its schema key (silent drop). Expanding blog_post[summary]
4337
+ // into params[blog_post][summary] mirrors the server's bracket_path exactly —
4338
+ // split at the first "[", then each non-empty bracket segment; an empty
4339
+ // trailing segment (tags[]) drops, matching how the JSON path's
4340
+ // expand_bracket_keys coerces the same name — so a multipart body and a JSON
4341
+ // body coerce identically for the same fields (the documented contract).
4342
+ // A flat name stays a single params[name] wrap, byte-identical to before.
4343
+ #wireKey(name) {
4344
+ const raw = String(name)
4345
+ const head = raw.indexOf("[")
4346
+ if (head === -1) return `params[${raw}]`
4347
+ const segments = [raw.slice(0, head), ...(raw.slice(head).match(/[^\[\]]+/g) ?? [])]
4348
+ return `params${segments.map((segment) => `[${segment}]`).join("")}`
4349
+ }
4350
+
4147
4351
  // Append a param leaf to FormData under its bracketed key. FormData carries
4148
4352
  // only strings, so a NON-scalar param (a nested object or an array) is
4149
4353
  // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
@@ -4207,7 +4411,41 @@ export default class extends Controller {
4207
4411
  // logic lives in the shared applyOps so runOps and the reactive:js stream
4208
4412
  // action interpret the SAME vocabulary the SAME way (client-side default-deny).
4209
4413
  #applyOps(list) {
4210
- applyOps(list, (args) => this.#opTargets(args))
4414
+ applyOps(
4415
+ list,
4416
+ (args) => this.#opTargets(args),
4417
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4418
+ )
4419
+ }
4420
+
4421
+ // Issue #237: the verbose gate for zero-target diagnostics — the
4422
+ // verbose_errors stamp (ON by default in dev/test) or full debug mode (a
4423
+ // debug user must never see less). Read live off the root like #debugEnabled.
4424
+ #verboseEnabled() {
4425
+ return this.element?.getAttribute?.("data-reactive-verbose") === "true" || this.#debugEnabled()
4426
+ }
4427
+
4428
+ // Issue #237: called when a selector-form target resolved to ZERO elements on
4429
+ // this root. Gated + deduped (module helpers); builds the trap-specific hint:
4430
+ // the root-self selector (root-scoped resolution never includes the root),
4431
+ // the nested-reactive-root ownership filter, or plain out-of-scope. All DOM
4432
+ // probes run only here — after a zero-match with the gate on.
4433
+ #diagnoseZeroTargets(label, args) {
4434
+ if (!this.#verboseEnabled()) return
4435
+ const to = args.to
4436
+ if (typeof to !== "string" || to === "" || to === "@root") return
4437
+ let hint = ""
4438
+ if (!args.global) {
4439
+ if (this.element?.matches?.(to)) {
4440
+ hint = " — the selector matches this component's own root, which root-scoped resolution never includes; use to: :root"
4441
+ } else if (countMatches(this.element, to) > 0) {
4442
+ hint = " — it matches only inside a nested reactive root (excluded by ownership scoping); use global: true"
4443
+ } else {
4444
+ const n = countMatches(globalThis.document, to)
4445
+ if (n > 0) hint = ` — it matches ${n} element(s) outside this scope; use global: true`
4446
+ }
4447
+ }
4448
+ emitZeroTargetWarn(label, to, `scoped to #${this.element?.id || "?"}`, hint)
4211
4449
  }
4212
4450
 
4213
4451
  // Resolve an op's targets: "@root" is this element; a selector resolves
@@ -4360,7 +4598,10 @@ export default class extends Controller {
4360
4598
  // this root's owned matches) or, with no `to:`, the trigger itself.
4361
4599
  #hintTargets(hint, trigger) {
4362
4600
  if (hint.to == null) return trigger ? [trigger] : []
4363
- return this.#opTargets({ to: hint.to })
4601
+ const targets = this.#opTargets({ to: hint.to })
4602
+ // Issue #237: a hint aimed at nothing is the same silent trap as an op.
4603
+ if (targets.length === 0) this.#diagnoseZeroTargets("busy/optimistic hint", { to: hint.to })
4604
+ return targets
4364
4605
  }
4365
4606
 
4366
4607
  // Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the