phlex-reactive 0.12.4 → 0.12.6

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
 
@@ -1043,6 +1055,215 @@ function runTransition(el, transition, flip) {
1043
1055
  setTimeout(cleanup, 350)
1044
1056
  }
1045
1057
 
1058
+ // ---------------------------------------------------------------------------
1059
+ // Client-only drafts (issue #239) — reactive_persist. A root that declares
1060
+ // data-reactive-persist='{"key","ttl","debounce"[,"fields"][,"restore"]}'
1061
+ // keeps a localStorage draft of every persistable OWNED control (write on
1062
+ // input/change, restore on connect, clear on a successful submit / TTL / the
1063
+ // persist_clear op). The storage layer is MODULE-LEVEL and keyed on the root
1064
+ // element — the controller's connect/write path and the persist_state /
1065
+ // persist_clear client ops (which receive only the element) share it. Every
1066
+ // storage access is try/catch'd: a private window, a quota error or blocked
1067
+ // storage degrades to "no draft", never a thrown bootstrap. Nothing here
1068
+ // leaves the browser: no token, no POST, values are replayed via
1069
+ // .value/.checked only (never HTML).
1070
+ // ---------------------------------------------------------------------------
1071
+ const PERSIST_VERSION = 1
1072
+ const PERSIST_PREFIX = "phlex-reactive:persist:"
1073
+ // Never persisted regardless of author intent: no server default to restore
1074
+ // into (hidden, file), secrets (password), and non-value controls.
1075
+ const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
1076
+ const PERSIST_STATE_ATTR = "data-reactive-persist-state"
1077
+ // Once-per-root guards: a malformed payload warns once; the storage-failure
1078
+ // dev note (debug mode only) prints once.
1079
+ const persistPayloadWarned = new WeakSet()
1080
+ const persistFailureNoted = new WeakSet()
1081
+
1082
+ // The root's parsed payload, or null (undeclared / malformed → warned once).
1083
+ // A control-level "off" (reactive_persist_skip) is never a root payload.
1084
+ function persistPayload(root) {
1085
+ const raw = root?.getAttribute?.("data-reactive-persist")
1086
+ if (!raw || raw === "off") return null
1087
+ try {
1088
+ const payload = JSON.parse(raw)
1089
+ if (payload && typeof payload === "object" && typeof payload.key === "string" && payload.key !== "") return payload
1090
+ } catch {
1091
+ // fall through to the warn
1092
+ }
1093
+ if (!persistPayloadWarned.has(root)) {
1094
+ persistPayloadWarned.add(root)
1095
+ console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(raw)} — persistence disabled`)
1096
+ }
1097
+ return null
1098
+ }
1099
+
1100
+ function persistStorage() {
1101
+ try {
1102
+ return typeof localStorage === "undefined" ? null : localStorage
1103
+ } catch {
1104
+ return null // the accessor itself can throw (blocked site data)
1105
+ }
1106
+ }
1107
+
1108
+ // The dev lens for "why did nothing come back": ONLY under data-reactive-debug
1109
+ // (Phlex::Reactive.debug), once per root — production stays silent.
1110
+ function persistNoteFailure(root, error) {
1111
+ if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistFailureNoted.has(root)) return
1112
+ persistFailureNoted.add(root)
1113
+ console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${error?.name ?? error})`)
1114
+ }
1115
+
1116
+ function persistKeyFor(payload) {
1117
+ return PERSIST_PREFIX + payload.key
1118
+ }
1119
+
1120
+ // Read + validate the draft: null when absent, unparsable, another schema
1121
+ // version, or expired (an expired draft is REMOVED on read). Returns
1122
+ // { fields, state } with state null when the draft carries none.
1123
+ function persistRead(root, payload) {
1124
+ const store = persistStorage()
1125
+ if (!store) return null
1126
+ let raw
1127
+ try {
1128
+ raw = store.getItem(persistKeyFor(payload))
1129
+ } catch (error) {
1130
+ persistNoteFailure(root, error)
1131
+ return null
1132
+ }
1133
+ if (!raw) return null
1134
+ let draft
1135
+ try {
1136
+ draft = JSON.parse(raw)
1137
+ } catch {
1138
+ return null
1139
+ }
1140
+ if (!draft || typeof draft !== "object" || draft.v !== PERSIST_VERSION) return null
1141
+ const ttlMs = Number(payload.ttl) * 1000
1142
+ if (!(Number(draft.savedAt) + ttlMs > Date.now())) {
1143
+ persistRemove(root, payload)
1144
+ return null
1145
+ }
1146
+ const fields = draft.fields && typeof draft.fields === "object" ? draft.fields : {}
1147
+ const state = draft.state && typeof draft.state === "object" ? draft.state : null
1148
+ return { fields, state }
1149
+ }
1150
+
1151
+ function persistWrite(root, payload, { fields, state }) {
1152
+ const store = persistStorage()
1153
+ if (!store) return false
1154
+ const draft = { v: PERSIST_VERSION, savedAt: Date.now(), fields }
1155
+ if (state) draft.state = state
1156
+ try {
1157
+ store.setItem(persistKeyFor(payload), JSON.stringify(draft))
1158
+ return true
1159
+ } catch (error) {
1160
+ persistNoteFailure(root, error)
1161
+ return false
1162
+ }
1163
+ }
1164
+
1165
+ function persistRemove(root, payload) {
1166
+ root?.removeAttribute?.(PERSIST_STATE_ATTR)
1167
+ const store = persistStorage()
1168
+ if (!store) return
1169
+ try {
1170
+ store.removeItem(persistKeyFor(payload))
1171
+ } catch (error) {
1172
+ persistNoteFailure(root, error)
1173
+ }
1174
+ }
1175
+
1176
+ // The persistable controls this root OWNS (#15: a nested reactive root's
1177
+ // controls are its own), minus the excluded types, the reactive_persist_skip
1178
+ // marker, and — when `fields` narrows the set — any undeclared name.
1179
+ function persistControls(root, payload) {
1180
+ const allow = Array.isArray(payload.fields) ? new Set(payload.fields) : null
1181
+ const out = []
1182
+ for (const el of root.querySelectorAll("input[name], select[name], textarea[name]")) {
1183
+ if (el.closest('[data-controller~="reactive"]') !== root) continue
1184
+ if (PERSIST_EXCLUDED_TYPES.has(el.type)) continue
1185
+ if (el.getAttribute("data-reactive-persist") === "off") continue
1186
+ if (allow && !allow.has(el.name)) continue
1187
+ out.push(el)
1188
+ }
1189
+ return out
1190
+ }
1191
+
1192
+ function persistSelectMultiple(el) {
1193
+ return el.tagName === "SELECT" && el.multiple
1194
+ }
1195
+
1196
+ // Snapshot the owned controls: radio → the checked value (null when the group
1197
+ // has none, so a restore leaves it alone), checkbox → checked, multi-select →
1198
+ // the selected values, else .value. Mirrors #collectFields' reads.
1199
+ function persistSnapshot(root, payload) {
1200
+ const fields = {}
1201
+ for (const el of persistControls(root, payload)) {
1202
+ const name = el.name
1203
+ if (el.type === "radio") {
1204
+ if (el.checked) fields[name] = el.value
1205
+ else if (!Object.hasOwn(fields, name)) fields[name] = null
1206
+ } else if (el.type === "checkbox") {
1207
+ fields[name] = el.checked
1208
+ } else if (persistSelectMultiple(el)) {
1209
+ fields[name] = [...el.options].filter((o) => o.selected).map((o) => o.value)
1210
+ } else {
1211
+ fields[name] = el.value
1212
+ }
1213
+ }
1214
+ return fields
1215
+ }
1216
+
1217
+ // Replay the draft into the owned controls. Default (restore: blank): a
1218
+ // control the server rendered NON-BLANK keeps its value — a 422 re-render's
1219
+ // submitted values beat an older draft. restore: "always" lets the draft win.
1220
+ // Values land via .value/.checked/.selected only — never HTML.
1221
+ function persistApply(root, payload, fields) {
1222
+ const always = payload.restore === "always"
1223
+ const controls = persistControls(root, payload)
1224
+ for (const el of controls) {
1225
+ if (!Object.hasOwn(fields, el.name)) continue
1226
+ const value = fields[el.name]
1227
+ if (value === null || value === undefined) continue
1228
+ if (el.type === "radio") {
1229
+ if (!always && controls.some((c) => c.type === "radio" && c.name === el.name && c.checked)) continue
1230
+ el.checked = el.value === String(value)
1231
+ } else if (el.type === "checkbox") {
1232
+ if (!always && el.checked) continue
1233
+ el.checked = Boolean(value)
1234
+ } else if (persistSelectMultiple(el)) {
1235
+ if (!always && [...el.options].some((o) => o.selected)) continue
1236
+ const wanted = new Set((Array.isArray(value) ? value : [value]).map(String))
1237
+ for (const o of el.options) o.selected = wanted.has(o.value)
1238
+ } else {
1239
+ if (!always && el.value !== "") continue
1240
+ el.value = String(value)
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ // The persist_state op body: merge a FLAT bag into the root's draft (re-
1246
+ // snapshotting the fields so the write is whole) and mirror it on the root.
1247
+ // A root without reactive_persist is a call-site bug — warn and skip.
1248
+ function persistWriteState(root, state) {
1249
+ const payload = persistPayload(root)
1250
+ if (!payload) {
1251
+ console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped")
1252
+ return
1253
+ }
1254
+ if (!state || typeof state !== "object") return
1255
+ const current = persistRead(root, payload)
1256
+ const merged = { ...(current?.state ?? {}), ...state }
1257
+ if (persistWrite(root, payload, { fields: persistSnapshot(root, payload), state: merged })) {
1258
+ root.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(merged))
1259
+ }
1260
+ }
1261
+
1262
+ function persistClearRoot(root) {
1263
+ const payload = persistPayload(root)
1264
+ if (payload) persistRemove(root, payload)
1265
+ }
1266
+
1046
1267
  // The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors
1047
1268
  // Phlex::Reactive::JS's vocabulary; an op name not in this map is
1048
1269
  // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
@@ -1119,6 +1340,13 @@ const CLIENT_OPS = Object.freeze({
1119
1340
  // an actor reply's stream from a broadcast's, and reply.js legitimately
1120
1341
  // carries this op.
1121
1342
  paste_into: (el) => pasteClipboardInto(el),
1343
+
1344
+ // Client-only drafts (issue #239): merge a flat state bag into the root's
1345
+ // reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
1346
+ // (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
1347
+ // subscriber's draft from a broadcast would be hostile.
1348
+ persist_state: (el, args) => persistWriteState(el, args.state),
1349
+ persist_clear: (el) => persistClearRoot(el),
1122
1350
  })
1123
1351
 
1124
1352
  // The form a submit op commits (issue #226), in order: the target itself when
@@ -1491,7 +1719,7 @@ function computeOpsList(raw) {
1491
1719
  // chain still applies — client-side default-deny, one bad op never takes down
1492
1720
  // its siblings. Object.hasOwn (not a bare read) so inherited Object members
1493
1721
  // ("constructor") can't masquerade as ops.
1494
- function applyOps(list, resolveTargets) {
1722
+ function applyOps(list, resolveTargets, onZeroTargets) {
1495
1723
  for (const entry of list) {
1496
1724
  if (!Array.isArray(entry)) continue
1497
1725
  const [name, args = {}] = entry
@@ -1499,10 +1727,70 @@ function applyOps(list, resolveTargets) {
1499
1727
  console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1500
1728
  continue
1501
1729
  }
1502
- for (const el of resolveTargets(args)) CLIENT_OPS[name](el, args)
1730
+ const targets = resolveTargets(args)
1731
+ if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1732
+ for (const el of targets) CLIENT_OPS[name](el, args)
1503
1733
  }
1504
1734
  }
1505
1735
 
1736
+ // --- zero-target diagnostics (issue #237) -----------------------------------
1737
+ // An op resolving ZERO targets is indistinguishable from a working no-op, and
1738
+ // the documented scoping traps (nested-root ownership filter, root-self
1739
+ // selector, stream default scope) all present exactly that way. Under the
1740
+ // verbose gate (data-reactive-verbose, stamped when Phlex::Reactive
1741
+ // .verbose_errors is on — dev/test by default — or the debug attr) warn ONCE
1742
+ // per unique (label, selector, scope) with a targeted hint when the element
1743
+ // EXISTS but sits outside the op's scope. Everything below runs only after a
1744
+ // zero-match with the gate on; production (no attr) pays one boolean per empty
1745
+ // resolution and never probes the DOM.
1746
+ //
1747
+ // Dedupe is keyed per document (page lifetime): a WeakMap entry per document
1748
+ // means a fresh page — or a fresh unit-harness stub — starts clean, and the
1749
+ // per-keystroke reducer ($ops) path can never flood the console.
1750
+ const zeroTargetWarnSets = new WeakMap()
1751
+
1752
+ function zeroTargetAlreadyWarned(key) {
1753
+ const doc = globalThis.document
1754
+ if (!doc) return true
1755
+ let seen = zeroTargetWarnSets.get(doc)
1756
+ if (!seen) {
1757
+ seen = new Set()
1758
+ zeroTargetWarnSets.set(doc, seen)
1759
+ }
1760
+ if (seen.has(key)) return true
1761
+ seen.add(key)
1762
+ return false
1763
+ }
1764
+
1765
+ // Guarded probes: unit harnesses stub partial documents/roots, and an exotic
1766
+ // selector could throw — a diagnostic must never break the op pipeline.
1767
+ function countMatches(node, selector) {
1768
+ try {
1769
+ return node?.querySelectorAll?.(selector)?.length ?? 0
1770
+ } catch {
1771
+ return 0
1772
+ }
1773
+ }
1774
+
1775
+ function emitZeroTargetWarn(label, to, scope, hint) {
1776
+ if (zeroTargetAlreadyWarned(`${label}|${to}|${scope}`)) return
1777
+ console.warn(`[phlex-reactive] ${label} matched zero targets for selector "${to}" (${scope})${hint}`)
1778
+ }
1779
+
1780
+ // The stream-path diagnoser (reactive:js). No ownership filter exists here, so
1781
+ // the only trap is the target-root scope: the selector matches document-wide
1782
+ // but the op was scoped to the stream's target root.
1783
+ function diagnoseStreamZeroTargets(name, args, root) {
1784
+ const to = args.to
1785
+ if (typeof to !== "string" || to === "" || to === "@root") return
1786
+ let hint = ""
1787
+ if (root && !args.global) {
1788
+ const n = countMatches(globalThis.document, to)
1789
+ if (n > 0) hint = ` — it matches ${n} element(s) outside the stream's target root; use global: true`
1790
+ }
1791
+ emitZeroTargetWarn(`client op "${name}"`, to, root ? `scoped to #${root.id || "?"}` : "document-scoped", hint)
1792
+ }
1793
+
1506
1794
  // Resolve a reactive:js op's targets against its `target` root (issue #97).
1507
1795
  // "@root" is the root element itself; a selector resolves WITHIN it; a bare
1508
1796
  // selector with no root (no `target` attr on the stream) resolves document-wide
@@ -1603,6 +1891,17 @@ export default class extends Controller {
1603
1891
  // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1604
1892
  // held for teardown.
1605
1893
  #boundSyncClipboard
1894
+ // Client-only drafts (issue #239): the parsed root payload (null when
1895
+ // undeclared), the restore-complete latch (no write may run before the
1896
+ // connect restore — a connect must never overwrite a draft with server
1897
+ // blanks), the ONE per-root trailing-edge write timer, and the bound
1898
+ // input/change/turbo:submit-end handlers held for teardown.
1899
+ #persistConfig = null
1900
+ #persistRestored = false
1901
+ #persistTimer = null
1902
+ #boundPersistInput
1903
+ #boundPersistChange
1904
+ #boundPersistSubmitEnd
1606
1905
 
1607
1906
  // Mark that a reactive controller actually connected, so the registration
1608
1907
  // guard above knows the controller was registered (issue #26 part 2).
@@ -1644,6 +1943,18 @@ export default class extends Controller {
1644
1943
  this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1645
1944
  }
1646
1945
 
1946
+ // Client-only drafts (issue #239) — ONLY when the root declares
1947
+ // data-reactive-persist (one attribute read otherwise). Runs FIRST among
1948
+ // the feature blocks ON PURPOSE: the restore writes the draft into the
1949
+ // owned controls, and every later connect seed (dirty baseline, show,
1950
+ // on-complete's arm-without-fire, filter, tags, nested-json, the compute
1951
+ // self-seed) then reads the restored DOM naturally — no synthetic
1952
+ // input/change events (which would FIRE reactive_on_complete bindings and
1953
+ // reducer $ops on page load). Restore is connect-only: a
1954
+ // turbo:morph-element is server truth arriving, never re-restored.
1955
+ this.#persistConfig = persistPayload(this.element)
1956
+ if (this.#persistConfig) this.#connectPersist()
1957
+
1647
1958
  // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
1648
1959
  // reactive_field(dirty:)), so a component that never uses it pays nothing (no
1649
1960
  // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
@@ -1823,6 +2134,10 @@ export default class extends Controller {
1823
2134
  // leading-edge timer holds no pending POST, but leaving it running would leak
1824
2135
  // it past the element's life.
1825
2136
  disconnect() {
2137
+ // Persist FIRST: flush a pending draft write while the fields are still
2138
+ // readable (Turbo disconnects before leaving the page — a fast visit
2139
+ // otherwise loses the last keystrokes).
2140
+ this.#teardownPersist()
1826
2141
  this.#clearAllDebounces()
1827
2142
  this.#clearAllThrottles()
1828
2143
  this.#teardownDirtyTracking()
@@ -2204,7 +2519,11 @@ export default class extends Controller {
2204
2519
  const fire = signature !== null && signature !== this.#computeOpsSignature && eventDriven
2205
2520
  this.#computeOpsSignature = signature
2206
2521
  if (!fire) return
2207
- applyOps(list, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
2522
+ applyOps(
2523
+ list,
2524
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
2525
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
2526
+ )
2208
2527
  }
2209
2528
 
2210
2529
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -3691,7 +4010,13 @@ export default class extends Controller {
3691
4010
  if (matches === null) return
3692
4011
  const fire = matches && !this.#onCompleteStates[i] && Boolean(event)
3693
4012
  this.#onCompleteStates[i] = matches
3694
- if (fire) applyOps(binding.ops, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
4013
+ if (fire) {
4014
+ applyOps(
4015
+ binding.ops,
4016
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
4017
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4018
+ )
4019
+ }
3695
4020
  })
3696
4021
  }
3697
4022
 
@@ -3896,6 +4221,87 @@ export default class extends Controller {
3896
4221
 
3897
4222
  // Remove the show-sync listeners on disconnect, so a stray event after a
3898
4223
  // Turbo morph/navigation never re-evaluates against a detached root.
4224
+ // Client-only drafts (issue #239): restore the draft into the owned
4225
+ // controls, expose the state bag, announce, then arm the write listeners.
4226
+ // The restore reads ONCE and never writes back — the restored latch stays
4227
+ // false until it completes, so no listener can clobber the draft with the
4228
+ // server's blanks. The submit-end listener is DOCUMENT-level (the event
4229
+ // fires on the form, which is usually an ANCESTOR of this root) and gated
4230
+ // on the form containing this root.
4231
+ #connectPersist() {
4232
+ const payload = this.#persistConfig
4233
+ this.#persistRestored = false
4234
+ const draft = persistRead(this.element, payload)
4235
+ if (draft) {
4236
+ persistApply(this.element, payload, draft.fields)
4237
+ if (draft.state) this.element.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(draft.state))
4238
+ this.#emit("reactive:persist-restored", { key: payload.key, fields: draft.fields, state: draft.state ?? {} })
4239
+ }
4240
+ this.#persistRestored = true
4241
+
4242
+ this.#boundPersistInput = () => this.#schedulePersistWrite()
4243
+ this.#boundPersistChange = () => this.#persistWriteNow()
4244
+ this.#boundPersistSubmitEnd = (event) => this.#persistSubmitEnd(event)
4245
+ this.element.addEventListener?.("input", this.#boundPersistInput)
4246
+ this.element.addEventListener?.("change", this.#boundPersistChange)
4247
+ document.addEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
4248
+ }
4249
+
4250
+ // Trailing-edge debounce for keystrokes — ONE timer per root (a snapshot is
4251
+ // a full pass, so per-field timers would only multiply writes).
4252
+ #schedulePersistWrite() {
4253
+ if (!this.#persistRestored) return
4254
+ const ms = Number(this.#persistConfig?.debounce) || 0
4255
+ if (ms <= 0) return this.#persistWriteNow()
4256
+ if (this.#persistTimer !== null) clearTimeout(this.#persistTimer)
4257
+ this.#persistTimer = setTimeout(() => {
4258
+ this.#persistTimer = null
4259
+ this.#persistWriteNow()
4260
+ }, ms)
4261
+ }
4262
+
4263
+ // Snapshot every persistable owned control and write. Re-reads the current
4264
+ // draft first so a state bag written by persist_state survives the write
4265
+ // (the bag lives in storage, not on the instance — the op has no instance).
4266
+ #persistWriteNow() {
4267
+ if (!this.#persistRestored || !this.#persistConfig) return
4268
+ if (this.#persistTimer !== null) {
4269
+ clearTimeout(this.#persistTimer)
4270
+ this.#persistTimer = null
4271
+ }
4272
+ const payload = this.#persistConfig
4273
+ const current = persistRead(this.element, payload)
4274
+ persistWrite(this.element, payload, { fields: persistSnapshot(this.element, payload), state: current?.state ?? null })
4275
+ }
4276
+
4277
+ // A SUCCESSFUL Turbo form submission of the form that owns this root
4278
+ // forgets the draft. tagName (not instanceof) so a cross-realm form counts.
4279
+ #persistSubmitEnd(event) {
4280
+ if (!event?.detail?.success) return
4281
+ const form = event.target
4282
+ if (form?.tagName !== "FORM" || typeof form.contains !== "function") return
4283
+ if (!form.contains(this.element)) return
4284
+ // Drop a pending keystroke write too — the disconnect flush that follows
4285
+ // Turbo's redirect visit would otherwise resurrect the just-cleared draft.
4286
+ if (this.#persistTimer !== null) {
4287
+ clearTimeout(this.#persistTimer)
4288
+ this.#persistTimer = null
4289
+ }
4290
+ persistRemove(this.element, this.#persistConfig)
4291
+ }
4292
+
4293
+ // Flush a pending write, then drop every listener (disconnect()).
4294
+ #teardownPersist() {
4295
+ if (!this.#persistConfig) return
4296
+ if (this.#persistTimer !== null) this.#persistWriteNow()
4297
+ this.element.removeEventListener?.("input", this.#boundPersistInput)
4298
+ this.element.removeEventListener?.("change", this.#boundPersistChange)
4299
+ document.removeEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
4300
+ this.#boundPersistInput = this.#boundPersistChange = this.#boundPersistSubmitEnd = undefined
4301
+ this.#persistConfig = null
4302
+ this.#persistRestored = false
4303
+ }
4304
+
3899
4305
  #teardownShowSync() {
3900
4306
  if (!this.#boundSyncShow) return
3901
4307
  this.element.removeEventListener?.("input", this.#boundSyncShow)
@@ -4233,19 +4639,39 @@ export default class extends Controller {
4233
4639
  fd.append("token", token)
4234
4640
  fd.append("act", action)
4235
4641
  for (const [key, value] of Object.entries(params)) {
4236
- this.#appendField(fd, `params[${key}]`, value)
4642
+ this.#appendField(fd, this.#wireKey(key), value)
4237
4643
  }
4238
4644
  const multiNames = this.#multiFileNames(files)
4239
4645
  for (const { name, file, multiple } of files) {
4240
4646
  // params[name][] when the input is `multiple` (array shape even for one
4241
4647
  // file) OR the name repeats across inputs; otherwise a lone scalar file.
4242
4648
  const asArray = multiple || multiNames.has(name)
4243
- const key = asArray ? `params[${name}][]` : `params[${name}]`
4649
+ const key = asArray ? `${this.#wireKey(name)}[]` : this.#wireKey(name)
4244
4650
  fd.append(key, file, file.name)
4245
4651
  }
4246
4652
  return fd
4247
4653
  }
4248
4654
 
4655
+ // The multipart wire key for a collected field/file name: params[...] with
4656
+ // the name's OWN brackets expanded into nesting segments (issue #231). The
4657
+ // old verbatim wrap of a Rails-bracketed name — params[blog_post[summary]] —
4658
+ // is unparseable by Rack: a scalar arrived as {"blog_post[summary" => {"]" =>
4659
+ // value}} (data corruption written through `update!` with a 200) and a file
4660
+ // never reached its schema key (silent drop). Expanding blog_post[summary]
4661
+ // into params[blog_post][summary] mirrors the server's bracket_path exactly —
4662
+ // split at the first "[", then each non-empty bracket segment; an empty
4663
+ // trailing segment (tags[]) drops, matching how the JSON path's
4664
+ // expand_bracket_keys coerces the same name — so a multipart body and a JSON
4665
+ // body coerce identically for the same fields (the documented contract).
4666
+ // A flat name stays a single params[name] wrap, byte-identical to before.
4667
+ #wireKey(name) {
4668
+ const raw = String(name)
4669
+ const head = raw.indexOf("[")
4670
+ if (head === -1) return `params[${raw}]`
4671
+ const segments = [raw.slice(0, head), ...(raw.slice(head).match(/[^\[\]]+/g) ?? [])]
4672
+ return `params${segments.map((segment) => `[${segment}]`).join("")}`
4673
+ }
4674
+
4249
4675
  // Append a param leaf to FormData under its bracketed key. FormData carries
4250
4676
  // only strings, so a NON-scalar param (a nested object or an array) is
4251
4677
  // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
@@ -4309,7 +4735,41 @@ export default class extends Controller {
4309
4735
  // logic lives in the shared applyOps so runOps and the reactive:js stream
4310
4736
  // action interpret the SAME vocabulary the SAME way (client-side default-deny).
4311
4737
  #applyOps(list) {
4312
- applyOps(list, (args) => this.#opTargets(args))
4738
+ applyOps(
4739
+ list,
4740
+ (args) => this.#opTargets(args),
4741
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4742
+ )
4743
+ }
4744
+
4745
+ // Issue #237: the verbose gate for zero-target diagnostics — the
4746
+ // verbose_errors stamp (ON by default in dev/test) or full debug mode (a
4747
+ // debug user must never see less). Read live off the root like #debugEnabled.
4748
+ #verboseEnabled() {
4749
+ return this.element?.getAttribute?.("data-reactive-verbose") === "true" || this.#debugEnabled()
4750
+ }
4751
+
4752
+ // Issue #237: called when a selector-form target resolved to ZERO elements on
4753
+ // this root. Gated + deduped (module helpers); builds the trap-specific hint:
4754
+ // the root-self selector (root-scoped resolution never includes the root),
4755
+ // the nested-reactive-root ownership filter, or plain out-of-scope. All DOM
4756
+ // probes run only here — after a zero-match with the gate on.
4757
+ #diagnoseZeroTargets(label, args) {
4758
+ if (!this.#verboseEnabled()) return
4759
+ const to = args.to
4760
+ if (typeof to !== "string" || to === "" || to === "@root") return
4761
+ let hint = ""
4762
+ if (!args.global) {
4763
+ if (this.element?.matches?.(to)) {
4764
+ hint = " — the selector matches this component's own root, which root-scoped resolution never includes; use to: :root"
4765
+ } else if (countMatches(this.element, to) > 0) {
4766
+ hint = " — it matches only inside a nested reactive root (excluded by ownership scoping); use global: true"
4767
+ } else {
4768
+ const n = countMatches(globalThis.document, to)
4769
+ if (n > 0) hint = ` — it matches ${n} element(s) outside this scope; use global: true`
4770
+ }
4771
+ }
4772
+ emitZeroTargetWarn(label, to, `scoped to #${this.element?.id || "?"}`, hint)
4313
4773
  }
4314
4774
 
4315
4775
  // Resolve an op's targets: "@root" is this element; a selector resolves
@@ -4462,7 +4922,10 @@ export default class extends Controller {
4462
4922
  // this root's owned matches) or, with no `to:`, the trigger itself.
4463
4923
  #hintTargets(hint, trigger) {
4464
4924
  if (hint.to == null) return trigger ? [trigger] : []
4465
- return this.#opTargets({ to: hint.to })
4925
+ const targets = this.#opTargets({ to: hint.to })
4926
+ // Issue #237: a hint aimed at nothing is the same silent trap as an op.
4927
+ if (targets.length === 0) this.#diagnoseZeroTargets("busy/optimistic hint", { to: hint.to })
4928
+ return targets
4466
4929
  }
4467
4930
 
4468
4931
  // Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the