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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +104 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +19 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +473 -10
- 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 +89 -0
- data/lib/phlex/reactive/component.rb +8 -1
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/response.rb +5 -1
- data/lib/phlex/reactive/show_conditions.rb +1 -2
- data/lib/phlex/reactive/streamable.rb +14 -5
- 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
|
|
|
@@ -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
|
-
|
|
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(
|
|
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)
|
|
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,
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
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
|