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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +54 -7
- data/app/controllers/phlex/reactive/actions_controller.rb +19 -6
- data/app/javascript/phlex/reactive/compute.js +7 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +254 -13
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +1 -1
- data/lib/phlex/reactive/component/helpers.rb +9 -0
- data/lib/phlex/reactive/component.rb +8 -1
- data/lib/phlex/reactive/js.rb +29 -0
- data/lib/phlex/reactive/response.rb +5 -1
- data/lib/phlex/reactive/show_conditions.rb +1 -2
- data/lib/phlex/reactive/streamable.rb +44 -28
- data/lib/phlex/reactive/test_helpers/system.rb +1 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +50 -0
- metadata +5 -5
|
@@ -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)
|
|
109
|
-
|
|
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
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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,
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
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
|