phlex-reactive 0.12.5 → 0.13.0
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 +55 -0
- data/README.md +103 -5
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +1 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +1 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +445 -0
- 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/helpers.rb +90 -0
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/streamable.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -1055,6 +1055,331 @@ function runTransition(el, transition, flip) {
|
|
|
1055
1055
|
setTimeout(cleanup, 350)
|
|
1056
1056
|
}
|
|
1057
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, and phlex-reactive never writes
|
|
1069
|
+
// markup into the DOM: native controls are replayed via
|
|
1070
|
+
// .value/.checked/.selected, a bare [contenteditable] via textContent, and a
|
|
1071
|
+
// rich editor (lexxy-editor, trix-editor — issue #241) through its OWN
|
|
1072
|
+
// `value` setter, the same sanitizing import path a paste takes. Never
|
|
1073
|
+
// innerHTML.
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
const PERSIST_VERSION = 1
|
|
1076
|
+
const PERSIST_PREFIX = "phlex-reactive:persist:"
|
|
1077
|
+
// Never persisted regardless of author intent: no server default to restore
|
|
1078
|
+
// into (hidden, file), secrets (password), and non-value controls.
|
|
1079
|
+
const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
|
|
1080
|
+
const PERSIST_STATE_ATTR = "data-reactive-persist-state"
|
|
1081
|
+
// The editor query #collectFields reads (minus the [name] guard — an editor's
|
|
1082
|
+
// name may live on its IDL `name` getter: Trix's `input=`-paired hidden input).
|
|
1083
|
+
const PERSIST_EDITOR_SELECTOR =
|
|
1084
|
+
":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
|
|
1085
|
+
const PERSIST_EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
|
|
1086
|
+
// An editor's own chrome: Lexxy renders its toolbar (a `lexxy-code-language`
|
|
1087
|
+
// select, the link dialog's `href` input) INSIDE <lexxy-editor>, Trix as a
|
|
1088
|
+
// sibling <trix-toolbar>. Those are named native controls that are not the
|
|
1089
|
+
// user's fields — never drafted, never restored into.
|
|
1090
|
+
const PERSIST_EDITOR_CHROME = "lexxy-editor, trix-editor, trix-toolbar"
|
|
1091
|
+
// The editors' own bubbling change events — the keystroke signal for the
|
|
1092
|
+
// draft write, since neither lets its contenteditable's native `input` bubble.
|
|
1093
|
+
const PERSIST_EDITOR_CHANGE_EVENTS = ["lexxy:change", "trix-change"]
|
|
1094
|
+
// "Is this editor empty" when it exposes no predicate of its own (Lexxy's
|
|
1095
|
+
// `isEmpty`, Trix's `editor.getDocument().isEmpty()`): Lexxy's own empty
|
|
1096
|
+
// list plus the Trix / contenteditable empties. Exact strings, no HTML
|
|
1097
|
+
// parsing — an attachment-only body stays non-blank.
|
|
1098
|
+
const PERSIST_EMPTY_HTML = new Set(["", "<p></p>", "<p><br></p>", "<div><br></div>"])
|
|
1099
|
+
// Once-per-root guards: a malformed payload warns once; the storage-failure
|
|
1100
|
+
// and editor-restore dev notes (debug mode only) print once each.
|
|
1101
|
+
const persistPayloadWarned = new WeakSet()
|
|
1102
|
+
const persistFailureNoted = new WeakSet()
|
|
1103
|
+
const persistEditorNoted = new WeakSet()
|
|
1104
|
+
|
|
1105
|
+
// The root's parsed payload, or null (undeclared / malformed → warned once).
|
|
1106
|
+
// A control-level "off" (reactive_persist_skip) is never a root payload.
|
|
1107
|
+
function persistPayload(root) {
|
|
1108
|
+
const raw = root?.getAttribute?.("data-reactive-persist")
|
|
1109
|
+
if (!raw || raw === "off") return null
|
|
1110
|
+
try {
|
|
1111
|
+
const payload = JSON.parse(raw)
|
|
1112
|
+
if (payload && typeof payload === "object" && typeof payload.key === "string" && payload.key !== "") return payload
|
|
1113
|
+
} catch {
|
|
1114
|
+
// fall through to the warn
|
|
1115
|
+
}
|
|
1116
|
+
if (!persistPayloadWarned.has(root)) {
|
|
1117
|
+
persistPayloadWarned.add(root)
|
|
1118
|
+
console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(raw)} — persistence disabled`)
|
|
1119
|
+
}
|
|
1120
|
+
return null
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function persistStorage() {
|
|
1124
|
+
try {
|
|
1125
|
+
return typeof localStorage === "undefined" ? null : localStorage
|
|
1126
|
+
} catch {
|
|
1127
|
+
return null // the accessor itself can throw (blocked site data)
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// The dev lens for "why did nothing come back": ONLY under data-reactive-debug
|
|
1132
|
+
// (Phlex::Reactive.debug), once per root — production stays silent.
|
|
1133
|
+
function persistNoteFailure(root, error) {
|
|
1134
|
+
if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistFailureNoted.has(root)) return
|
|
1135
|
+
persistFailureNoted.add(root)
|
|
1136
|
+
console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${error?.name ?? error})`)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function persistKeyFor(payload) {
|
|
1140
|
+
return PERSIST_PREFIX + payload.key
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Read + validate the draft: null when absent, unparsable, another schema
|
|
1144
|
+
// version, or expired (an expired draft is REMOVED on read). Returns
|
|
1145
|
+
// { fields, state } with state null when the draft carries none.
|
|
1146
|
+
function persistRead(root, payload) {
|
|
1147
|
+
const store = persistStorage()
|
|
1148
|
+
if (!store) return null
|
|
1149
|
+
let raw
|
|
1150
|
+
try {
|
|
1151
|
+
raw = store.getItem(persistKeyFor(payload))
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
persistNoteFailure(root, error)
|
|
1154
|
+
return null
|
|
1155
|
+
}
|
|
1156
|
+
if (!raw) return null
|
|
1157
|
+
let draft
|
|
1158
|
+
try {
|
|
1159
|
+
draft = JSON.parse(raw)
|
|
1160
|
+
} catch {
|
|
1161
|
+
return null
|
|
1162
|
+
}
|
|
1163
|
+
if (!draft || typeof draft !== "object" || draft.v !== PERSIST_VERSION) return null
|
|
1164
|
+
const ttlMs = Number(payload.ttl) * 1000
|
|
1165
|
+
if (!(Number(draft.savedAt) + ttlMs > Date.now())) {
|
|
1166
|
+
persistRemove(root, payload)
|
|
1167
|
+
return null
|
|
1168
|
+
}
|
|
1169
|
+
const fields = draft.fields && typeof draft.fields === "object" ? draft.fields : {}
|
|
1170
|
+
const state = draft.state && typeof draft.state === "object" ? draft.state : null
|
|
1171
|
+
return { fields, state }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function persistWrite(root, payload, { fields, state }) {
|
|
1175
|
+
const store = persistStorage()
|
|
1176
|
+
if (!store) return false
|
|
1177
|
+
const draft = { v: PERSIST_VERSION, savedAt: Date.now(), fields }
|
|
1178
|
+
if (state) draft.state = state
|
|
1179
|
+
try {
|
|
1180
|
+
store.setItem(persistKeyFor(payload), JSON.stringify(draft))
|
|
1181
|
+
return true
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
persistNoteFailure(root, error)
|
|
1184
|
+
return false
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function persistRemove(root, payload) {
|
|
1189
|
+
root?.removeAttribute?.(PERSIST_STATE_ATTR)
|
|
1190
|
+
const store = persistStorage()
|
|
1191
|
+
if (!store) return
|
|
1192
|
+
try {
|
|
1193
|
+
store.removeItem(persistKeyFor(payload))
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
persistNoteFailure(root, error)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// The persistable controls this root OWNS (#15: a nested reactive root's
|
|
1200
|
+
// controls are its own), minus the excluded types, the reactive_persist_skip
|
|
1201
|
+
// marker, and — when `fields` narrows the set — any undeclared name. Each
|
|
1202
|
+
// entry is { el, name, kind } with kind "native" (input/select/textarea),
|
|
1203
|
+
// "editor" (lexxy-editor / trix-editor) or "contenteditable" (a bare named
|
|
1204
|
+
// editable element) — issue #241.
|
|
1205
|
+
function persistControls(root, payload) {
|
|
1206
|
+
const allow = Array.isArray(payload.fields) ? new Set(payload.fields) : null
|
|
1207
|
+
const out = []
|
|
1208
|
+
const owned = (el) =>
|
|
1209
|
+
el.closest('[data-controller~="reactive"]') === root && el.getAttribute("data-reactive-persist") !== "off"
|
|
1210
|
+
for (const el of root.querySelectorAll("input[name], select[name], textarea[name]")) {
|
|
1211
|
+
if (!owned(el) || PERSIST_EXCLUDED_TYPES.has(el.type) || el.closest(PERSIST_EDITOR_CHROME)) continue
|
|
1212
|
+
if (allow && !allow.has(el.name)) continue
|
|
1213
|
+
out.push({ el, name: el.name, kind: "native" })
|
|
1214
|
+
}
|
|
1215
|
+
for (const el of root.querySelectorAll(PERSIST_EDITOR_SELECTOR)) {
|
|
1216
|
+
if (!owned(el)) continue
|
|
1217
|
+
const name = persistEditorName(el)
|
|
1218
|
+
if (!name || (allow && !allow.has(name))) continue
|
|
1219
|
+
out.push({ el, name, kind: PERSIST_EDITOR_TAGS.has(el.localName) ? "editor" : "contenteditable" })
|
|
1220
|
+
}
|
|
1221
|
+
return out
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// The attribute first (Lexxy, a bare contenteditable — which has no `name`
|
|
1225
|
+
// IDL property at all), then the IDL getter (Trix resolves it through its
|
|
1226
|
+
// `input=`-paired hidden input, which is itself excluded as type=hidden).
|
|
1227
|
+
function persistEditorName(el) {
|
|
1228
|
+
return el.getAttribute("name") || (typeof el.name === "string" && el.name) || null
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// An editor is READY once its custom element has upgraded and connected: only
|
|
1232
|
+
// then does it expose the string `value` accessor (Lexxy's setter throws
|
|
1233
|
+
// before connectedCallback created its editor; Trix's discards the value).
|
|
1234
|
+
function persistEditorReady(el) {
|
|
1235
|
+
return typeof el.value === "string"
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
|
|
1239
|
+
// `editor.getDocument().isEmpty()`), else the exact-string fallback. An
|
|
1240
|
+
// attachment-only server body is therefore NON-blank and never overwritten.
|
|
1241
|
+
function persistEditorBlank(el) {
|
|
1242
|
+
if (typeof el.isEmpty === "boolean") return el.isEmpty
|
|
1243
|
+
const doc = el.editor?.getDocument?.()
|
|
1244
|
+
if (typeof doc?.isEmpty === "function") return doc.isEmpty()
|
|
1245
|
+
return PERSIST_EMPTY_HTML.has(el.value.trim())
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// The editor-restore dev lens: ONLY under data-reactive-debug, once per root.
|
|
1249
|
+
function persistNoteEditorFailure(root, name, error) {
|
|
1250
|
+
if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistEditorNoted.has(root)) return
|
|
1251
|
+
persistEditorNoted.add(root)
|
|
1252
|
+
console.info(
|
|
1253
|
+
`[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(name)} — ${error?.message ?? error}`,
|
|
1254
|
+
)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function persistSelectMultiple(el) {
|
|
1258
|
+
return el.tagName === "SELECT" && el.multiple
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Snapshot the owned controls: radio → the checked value (null when the group
|
|
1262
|
+
// has none, so a restore leaves it alone), checkbox → checked, multi-select →
|
|
1263
|
+
// the selected values, a rich editor → its serialized `value` (omitted while
|
|
1264
|
+
// the element hasn't upgraded — never a phantom ""), a bare contenteditable →
|
|
1265
|
+
// its textContent, else .value. Mirrors #collectFields' reads.
|
|
1266
|
+
function persistSnapshot(root, payload) {
|
|
1267
|
+
const fields = {}
|
|
1268
|
+
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1269
|
+
if (kind === "editor") {
|
|
1270
|
+
if (persistEditorReady(el)) fields[name] = el.value
|
|
1271
|
+
} else if (kind === "contenteditable") {
|
|
1272
|
+
fields[name] = el.textContent ?? ""
|
|
1273
|
+
} else if (el.type === "radio") {
|
|
1274
|
+
if (el.checked) fields[name] = el.value
|
|
1275
|
+
else if (!Object.hasOwn(fields, name)) fields[name] = null
|
|
1276
|
+
} else if (el.type === "checkbox") {
|
|
1277
|
+
fields[name] = el.checked
|
|
1278
|
+
} else if (persistSelectMultiple(el)) {
|
|
1279
|
+
fields[name] = [...el.options].filter((o) => o.selected).map((o) => o.value)
|
|
1280
|
+
} else {
|
|
1281
|
+
fields[name] = el.value
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return fields
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Replay the draft into the owned controls. Default (restore: blank): a
|
|
1288
|
+
// control the server rendered NON-BLANK keeps its value — a 422 re-render's
|
|
1289
|
+
// submitted values beat an older draft. restore: "always" lets the draft win.
|
|
1290
|
+
// Values land via .value/.checked/.selected, textContent, or the editor's own
|
|
1291
|
+
// `value` setter — never HTML written by us.
|
|
1292
|
+
function persistApply(root, payload, fields) {
|
|
1293
|
+
const always = payload.restore === "always"
|
|
1294
|
+
const controls = persistControls(root, payload)
|
|
1295
|
+
for (const { el, name, kind } of controls) {
|
|
1296
|
+
if (!Object.hasOwn(fields, name)) continue
|
|
1297
|
+
const value = fields[name]
|
|
1298
|
+
if (value === null || value === undefined) continue
|
|
1299
|
+
if (kind === "editor") {
|
|
1300
|
+
persistApplyEditor(root, el, name, value, always)
|
|
1301
|
+
} else if (kind === "contenteditable") {
|
|
1302
|
+
if (!always && (el.textContent ?? "").trim() !== "") continue
|
|
1303
|
+
el.textContent = String(value)
|
|
1304
|
+
} else if (el.type === "radio") {
|
|
1305
|
+
if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
|
|
1306
|
+
el.checked = el.value === String(value)
|
|
1307
|
+
} else if (el.type === "checkbox") {
|
|
1308
|
+
if (!always && el.checked) continue
|
|
1309
|
+
el.checked = Boolean(value)
|
|
1310
|
+
} else if (persistSelectMultiple(el)) {
|
|
1311
|
+
if (!always && [...el.options].some((o) => o.selected)) continue
|
|
1312
|
+
const wanted = new Set((Array.isArray(value) ? value : [value]).map(String))
|
|
1313
|
+
for (const o of el.options) o.selected = wanted.has(o.value)
|
|
1314
|
+
} else {
|
|
1315
|
+
if (!always && el.value !== "") continue
|
|
1316
|
+
el.value = String(value)
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
persistDeferEditors(root, payload, fields)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// A ready editor takes the value through its own setter; the setter is the
|
|
1323
|
+
// editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
|
|
1324
|
+
// sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
|
|
1325
|
+
function persistApplyEditor(root, el, name, value, always) {
|
|
1326
|
+
if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
|
|
1327
|
+
if (!always && !persistEditorBlank(el)) return
|
|
1328
|
+
try {
|
|
1329
|
+
el.value = String(value)
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
persistNoteEditorFailure(root, name, error)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// An editor whose custom element is not defined yet (Trix defines its elements
|
|
1336
|
+
// in a setTimeout after load; a lazily imported Lexxy) cannot take a value now.
|
|
1337
|
+
// Re-apply per TAG once it is defined — re-querying the controls (the upgrade
|
|
1338
|
+
// may replace the node) and re-checking restore: blank at that moment — unless
|
|
1339
|
+
// the root has left the document meanwhile.
|
|
1340
|
+
function persistDeferEditors(root, payload, fields) {
|
|
1341
|
+
const registry = globalThis.customElements
|
|
1342
|
+
if (typeof registry?.whenDefined !== "function") return
|
|
1343
|
+
const pending = new Set()
|
|
1344
|
+
for (const el of root.querySelectorAll("lexxy-editor, trix-editor")) {
|
|
1345
|
+
if (!persistEditorReady(el) && !registry.get?.(el.localName)) pending.add(el.localName)
|
|
1346
|
+
}
|
|
1347
|
+
const always = payload.restore === "always"
|
|
1348
|
+
for (const tag of pending) {
|
|
1349
|
+
registry.whenDefined(tag).then(() => {
|
|
1350
|
+
if (!root.isConnected) return
|
|
1351
|
+
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1352
|
+
if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
|
|
1353
|
+
const value = fields[name]
|
|
1354
|
+
if (value === null || value === undefined) continue
|
|
1355
|
+
persistApplyEditor(root, el, name, value, always)
|
|
1356
|
+
}
|
|
1357
|
+
})
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// The persist_state op body: merge a FLAT bag into the root's draft (re-
|
|
1362
|
+
// snapshotting the fields so the write is whole) and mirror it on the root.
|
|
1363
|
+
// A root without reactive_persist is a call-site bug — warn and skip.
|
|
1364
|
+
function persistWriteState(root, state) {
|
|
1365
|
+
const payload = persistPayload(root)
|
|
1366
|
+
if (!payload) {
|
|
1367
|
+
console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped")
|
|
1368
|
+
return
|
|
1369
|
+
}
|
|
1370
|
+
if (!state || typeof state !== "object") return
|
|
1371
|
+
const current = persistRead(root, payload)
|
|
1372
|
+
const merged = { ...(current?.state ?? {}), ...state }
|
|
1373
|
+
if (persistWrite(root, payload, { fields: persistSnapshot(root, payload), state: merged })) {
|
|
1374
|
+
root.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(merged))
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function persistClearRoot(root) {
|
|
1379
|
+
const payload = persistPayload(root)
|
|
1380
|
+
if (payload) persistRemove(root, payload)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1058
1383
|
// The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors
|
|
1059
1384
|
// Phlex::Reactive::JS's vocabulary; an op name not in this map is
|
|
1060
1385
|
// warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
|
|
@@ -1131,6 +1456,13 @@ const CLIENT_OPS = Object.freeze({
|
|
|
1131
1456
|
// an actor reply's stream from a broadcast's, and reply.js legitimately
|
|
1132
1457
|
// carries this op.
|
|
1133
1458
|
paste_into: (el) => pasteClipboardInto(el),
|
|
1459
|
+
|
|
1460
|
+
// Client-only drafts (issue #239): merge a flat state bag into the root's
|
|
1461
|
+
// reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
|
|
1462
|
+
// (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
|
|
1463
|
+
// subscriber's draft from a broadcast would be hostile.
|
|
1464
|
+
persist_state: (el, args) => persistWriteState(el, args.state),
|
|
1465
|
+
persist_clear: (el) => persistClearRoot(el),
|
|
1134
1466
|
})
|
|
1135
1467
|
|
|
1136
1468
|
// The form a submit op commits (issue #226), in order: the target itself when
|
|
@@ -1675,6 +2007,17 @@ export default class extends Controller {
|
|
|
1675
2007
|
// Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
|
|
1676
2008
|
// held for teardown.
|
|
1677
2009
|
#boundSyncClipboard
|
|
2010
|
+
// Client-only drafts (issue #239): the parsed root payload (null when
|
|
2011
|
+
// undeclared), the restore-complete latch (no write may run before the
|
|
2012
|
+
// connect restore — a connect must never overwrite a draft with server
|
|
2013
|
+
// blanks), the ONE per-root trailing-edge write timer, and the bound
|
|
2014
|
+
// input/change/turbo:submit-end handlers held for teardown.
|
|
2015
|
+
#persistConfig = null
|
|
2016
|
+
#persistRestored = false
|
|
2017
|
+
#persistTimer = null
|
|
2018
|
+
#boundPersistInput
|
|
2019
|
+
#boundPersistChange
|
|
2020
|
+
#boundPersistSubmitEnd
|
|
1678
2021
|
|
|
1679
2022
|
// Mark that a reactive controller actually connected, so the registration
|
|
1680
2023
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -1716,6 +2059,18 @@ export default class extends Controller {
|
|
|
1716
2059
|
this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
1717
2060
|
}
|
|
1718
2061
|
|
|
2062
|
+
// Client-only drafts (issue #239) — ONLY when the root declares
|
|
2063
|
+
// data-reactive-persist (one attribute read otherwise). Runs FIRST among
|
|
2064
|
+
// the feature blocks ON PURPOSE: the restore writes the draft into the
|
|
2065
|
+
// owned controls, and every later connect seed (dirty baseline, show,
|
|
2066
|
+
// on-complete's arm-without-fire, filter, tags, nested-json, the compute
|
|
2067
|
+
// self-seed) then reads the restored DOM naturally — no synthetic
|
|
2068
|
+
// input/change events (which would FIRE reactive_on_complete bindings and
|
|
2069
|
+
// reducer $ops on page load). Restore is connect-only: a
|
|
2070
|
+
// turbo:morph-element is server truth arriving, never re-restored.
|
|
2071
|
+
this.#persistConfig = persistPayload(this.element)
|
|
2072
|
+
if (this.#persistConfig) this.#connectPersist()
|
|
2073
|
+
|
|
1719
2074
|
// Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
|
|
1720
2075
|
// reactive_field(dirty:)), so a component that never uses it pays nothing (no
|
|
1721
2076
|
// baseline scan, no morph listener on every broadcast). A plain (outerHTML)
|
|
@@ -1895,6 +2250,10 @@ export default class extends Controller {
|
|
|
1895
2250
|
// leading-edge timer holds no pending POST, but leaving it running would leak
|
|
1896
2251
|
// it past the element's life.
|
|
1897
2252
|
disconnect() {
|
|
2253
|
+
// Persist FIRST: flush a pending draft write while the fields are still
|
|
2254
|
+
// readable (Turbo disconnects before leaving the page — a fast visit
|
|
2255
|
+
// otherwise loses the last keystrokes).
|
|
2256
|
+
this.#teardownPersist()
|
|
1898
2257
|
this.#clearAllDebounces()
|
|
1899
2258
|
this.#clearAllThrottles()
|
|
1900
2259
|
this.#teardownDirtyTracking()
|
|
@@ -3978,6 +4337,92 @@ export default class extends Controller {
|
|
|
3978
4337
|
|
|
3979
4338
|
// Remove the show-sync listeners on disconnect, so a stray event after a
|
|
3980
4339
|
// Turbo morph/navigation never re-evaluates against a detached root.
|
|
4340
|
+
// Client-only drafts (issue #239): restore the draft into the owned
|
|
4341
|
+
// controls, expose the state bag, announce, then arm the write listeners.
|
|
4342
|
+
// The restore reads ONCE and never writes back — the restored latch stays
|
|
4343
|
+
// false until it completes, so no listener can clobber the draft with the
|
|
4344
|
+
// server's blanks. The submit-end listener is DOCUMENT-level (the event
|
|
4345
|
+
// fires on the form, which is usually an ANCESTOR of this root) and gated
|
|
4346
|
+
// on the form containing this root.
|
|
4347
|
+
#connectPersist() {
|
|
4348
|
+
const payload = this.#persistConfig
|
|
4349
|
+
this.#persistRestored = false
|
|
4350
|
+
const draft = persistRead(this.element, payload)
|
|
4351
|
+
if (draft) {
|
|
4352
|
+
persistApply(this.element, payload, draft.fields)
|
|
4353
|
+
if (draft.state) this.element.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(draft.state))
|
|
4354
|
+
this.#emit("reactive:persist-restored", { key: payload.key, fields: draft.fields, state: draft.state ?? {} })
|
|
4355
|
+
}
|
|
4356
|
+
this.#persistRestored = true
|
|
4357
|
+
|
|
4358
|
+
this.#boundPersistInput = () => this.#schedulePersistWrite()
|
|
4359
|
+
this.#boundPersistChange = () => this.#persistWriteNow()
|
|
4360
|
+
this.#boundPersistSubmitEnd = (event) => this.#persistSubmitEnd(event)
|
|
4361
|
+
this.element.addEventListener?.("input", this.#boundPersistInput)
|
|
4362
|
+
this.element.addEventListener?.("change", this.#boundPersistChange)
|
|
4363
|
+
// Rich editors (#241): Lexical and Trix swallow the native `input` of
|
|
4364
|
+
// their contenteditable, so their own bubbling change events are the
|
|
4365
|
+
// keystroke signal — same trailing-edge debounce as `input`.
|
|
4366
|
+
for (const event of PERSIST_EDITOR_CHANGE_EVENTS) this.element.addEventListener?.(event, this.#boundPersistInput)
|
|
4367
|
+
document.addEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
// Trailing-edge debounce for keystrokes — ONE timer per root (a snapshot is
|
|
4371
|
+
// a full pass, so per-field timers would only multiply writes).
|
|
4372
|
+
#schedulePersistWrite() {
|
|
4373
|
+
if (!this.#persistRestored) return
|
|
4374
|
+
const ms = Number(this.#persistConfig?.debounce) || 0
|
|
4375
|
+
if (ms <= 0) return this.#persistWriteNow()
|
|
4376
|
+
if (this.#persistTimer !== null) clearTimeout(this.#persistTimer)
|
|
4377
|
+
this.#persistTimer = setTimeout(() => {
|
|
4378
|
+
this.#persistTimer = null
|
|
4379
|
+
this.#persistWriteNow()
|
|
4380
|
+
}, ms)
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
// Snapshot every persistable owned control and write. Re-reads the current
|
|
4384
|
+
// draft first so a state bag written by persist_state survives the write
|
|
4385
|
+
// (the bag lives in storage, not on the instance — the op has no instance).
|
|
4386
|
+
#persistWriteNow() {
|
|
4387
|
+
if (!this.#persistRestored || !this.#persistConfig) return
|
|
4388
|
+
if (this.#persistTimer !== null) {
|
|
4389
|
+
clearTimeout(this.#persistTimer)
|
|
4390
|
+
this.#persistTimer = null
|
|
4391
|
+
}
|
|
4392
|
+
const payload = this.#persistConfig
|
|
4393
|
+
const current = persistRead(this.element, payload)
|
|
4394
|
+
persistWrite(this.element, payload, { fields: persistSnapshot(this.element, payload), state: current?.state ?? null })
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
// A SUCCESSFUL Turbo form submission of the form that owns this root
|
|
4398
|
+
// forgets the draft. tagName (not instanceof) so a cross-realm form counts.
|
|
4399
|
+
#persistSubmitEnd(event) {
|
|
4400
|
+
if (!event?.detail?.success) return
|
|
4401
|
+
const form = event.target
|
|
4402
|
+
if (form?.tagName !== "FORM" || typeof form.contains !== "function") return
|
|
4403
|
+
if (!form.contains(this.element)) return
|
|
4404
|
+
// Drop a pending keystroke write too — the disconnect flush that follows
|
|
4405
|
+
// Turbo's redirect visit would otherwise resurrect the just-cleared draft.
|
|
4406
|
+
if (this.#persistTimer !== null) {
|
|
4407
|
+
clearTimeout(this.#persistTimer)
|
|
4408
|
+
this.#persistTimer = null
|
|
4409
|
+
}
|
|
4410
|
+
persistRemove(this.element, this.#persistConfig)
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4413
|
+
// Flush a pending write, then drop every listener (disconnect()).
|
|
4414
|
+
#teardownPersist() {
|
|
4415
|
+
if (!this.#persistConfig) return
|
|
4416
|
+
if (this.#persistTimer !== null) this.#persistWriteNow()
|
|
4417
|
+
this.element.removeEventListener?.("input", this.#boundPersistInput)
|
|
4418
|
+
this.element.removeEventListener?.("change", this.#boundPersistChange)
|
|
4419
|
+
for (const event of PERSIST_EDITOR_CHANGE_EVENTS) this.element.removeEventListener?.(event, this.#boundPersistInput)
|
|
4420
|
+
document.removeEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
|
|
4421
|
+
this.#boundPersistInput = this.#boundPersistChange = this.#boundPersistSubmitEnd = undefined
|
|
4422
|
+
this.#persistConfig = null
|
|
4423
|
+
this.#persistRestored = false
|
|
4424
|
+
}
|
|
4425
|
+
|
|
3981
4426
|
#teardownShowSync() {
|
|
3982
4427
|
if (!this.#boundSyncShow) return
|
|
3983
4428
|
this.element.removeEventListener?.("input", this.#boundSyncShow)
|