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.
@@ -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)