phlex-reactive 0.12.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec14089cc43f0bf439305f48b9fca3ee8b0fe2ba83b44b341e22370ad11fc22d
4
- data.tar.gz: 101ae52fce38a343185a2699082c79709dadf609d9401f199b9b9c5f181dda9c
3
+ metadata.gz: 7a9ece1128621af6ad4b9b8e249c1005bcfd063e0aa70235800292f156ac4419
4
+ data.tar.gz: f8613e2ec77fb55d1f9b5b0b30719382ef5e52c2a34d1ff6bcc29f9a017bafc7
5
5
  SHA512:
6
- metadata.gz: '083b640658e403acc1bfeb311d0c22051cd3a2b275a4f44084dfb9c52755fab481eb63c7458e593eb43741d6b3726b8c8e7dadeb2059bcd9b9818cae64768937'
7
- data.tar.gz: 3984b48e58eb838ed9bfb009d50444babd518831eb5d2fc7c0f77dc577060b146729f428e7bf1acead2281dd86547fc86130c43ba245b6a4956acf0d5e39d04f
6
+ metadata.gz: 19130860688333ec585f3da94ff25be2f8fc8c3fd103c023c98d4ac528921d7b02a7362448b864f81b435d35fc8946e0a3be7ba41f70f57751b115ed09b9a394
7
+ data.tar.gz: a6efc22b0270eb3e1f4cc7732cc1394acd40dd80de81b12a1dd792df9d2e3952bf5070872e21884cdcc9a5c91211791e74fc31e81f2f25cc1116587446a636c3
data/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`reactive_persist` — client-only localStorage drafts (#239).** "Don't make
12
+ me start over": spread `reactive_persist(key:, ttl: 7.days)` on a root and
13
+ the generic controller keeps a `localStorage` draft of every **owned**
14
+ control — a debounced write on `input`, immediate on `change`, flushed on
15
+ disconnect — and restores it on the next connect, **first** among the
16
+ client bindings so `reactive_show` / `reactive_on_complete` (armed, never
17
+ fired) / `reactive_filter` / a compute root read the restored values on
18
+ first paint with no synthetic events. A draft lands only in controls the
19
+ server rendered blank (`restore: :always` lets it win); a morph is never
20
+ re-restored. Cleared by a successful `turbo:submit-end` of the containing
21
+ form, `ttl` expiry, or the new actor-only `js.persist_clear` op;
22
+ `js.persist_state(step: 2)` merges a flat state bag into the draft (restored
23
+ as `data-reactive-persist-state` + the `reactive:persist-restored` event).
24
+ `hidden`/`file`/`password`/`submit` controls, `reactive_persist_skip`
25
+ markers, nested roots and rich-text editors are never persisted; `fields:`
26
+ narrows the set. Storage failures are silent (a `console.info` under
27
+ `Phlex::Reactive.debug`). Works from `ClientBindings` (no token, no POST).
28
+
11
29
  - **Dev-mode warning when a client op resolves zero targets (#237).** A client
12
30
  op that matches nothing is indistinguishable from a working no-op, and the
13
31
  three documented scoping traps — the nested-root ownership filter, a selector
data/README.md CHANGED
@@ -385,11 +385,13 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
385
385
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
386
386
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
387
387
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
388
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), and `paste_into` (read the clipboard into a field, gesture-gated) — chainable. |
388
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
389
389
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
390
390
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
391
391
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
392
  | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
393
+ | `reactive_persist(key:, ttl: 7.days)` | **Client-only drafts**: spread on the **root** (once) and the generic controller keeps a `localStorage` draft of every **owned** control — debounced write on `input`, immediate on `change`, flushed on disconnect, restored into **blank** controls on the next connect (`restore: :always` lets the draft win), cleared by a successful Turbo submit / `ttl` / `js.persist_clear`. Never hidden/file/password; `reactive_persist_skip` opts a control out; `fields:` narrows. See [Client-only drafts](#client-only-drafts-reactive_persist). |
394
+ | `js.persist_state(step: 2)` / `js.persist_clear` | The draft ops (actor-only): merge a flat state bag into the draft (restored as `data-reactive-persist-state` + the `reactive:persist-restored` event) / forget the draft. |
393
395
  | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
394
396
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
395
397
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
@@ -1103,6 +1105,82 @@ field; a missing owned field reads as blank — fail-closed). Every referenced
1103
1105
  field must be owned by the declaring root; a target whose fields are all
1104
1106
  unowned is left alone, like the single-field skip.
1105
1107
 
1108
+ ### Client-only drafts (`reactive_persist`)
1109
+
1110
+ "Don't make me start over": a public application form, a multi-step wizard,
1111
+ a long comment box — the user types, navigates away, comes back, and expects
1112
+ their draft. Nothing the server needs until submit, no signed-in user to
1113
+ autosave for. `reactive_persist` (#239) is the `reactive_show`-shaped answer:
1114
+ a **declared, client-only** binding over the fields the root **owns**, no
1115
+ token, no POST, no expression surface — the generic controller keeps a
1116
+ `localStorage` draft and every hand-rolled "local save" Stimulus controller
1117
+ goes away.
1118
+
1119
+ ```ruby
1120
+ class ApplicationForm < ApplicationComponent
1121
+ include Phlex::Reactive::ClientBindings # or the full Component
1122
+ reactive_scope :apply
1123
+
1124
+ def view_template
1125
+ form(action: "/applications", method: "post") do
1126
+ div(**mix(reactive_root(id: "apply"), reactive_persist(key: "village-apply", ttl: 7.days))) do
1127
+ input(**reactive_field(:name)) # persisted
1128
+ textarea(**reactive_field(:bio)) # persisted
1129
+ input(name: "fuckery", **reactive_persist_skip) # honeypot — never
1130
+ input(type: "hidden", name: "apply[tz]") # hidden — never (default)
1131
+ button(**on_client(:click, js.persist_state(step: 2))) { "Next" }
1132
+ button(**on_client(:click, js.persist_clear)) { "Discard draft" }
1133
+ button(type: "submit") { "Apply" }
1134
+ end
1135
+ end
1136
+ end
1137
+ end
1138
+ ```
1139
+
1140
+ Spread it on the **root** (`mix` with `reactive_root`), **once per root**. One
1141
+ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce":300}'`.
1142
+
1143
+ - **Write** — on `input` (trailing-edge debounce, `debounce:` ms, default 300)
1144
+ and immediately on `change`; a pending write is **flushed on disconnect**, so
1145
+ a fast Turbo navigation never loses the last keystrokes. The snapshot is a
1146
+ full pass over the owned controls: radios store the checked value, checkboxes
1147
+ the checked state, `<select multiple>` an array, everything else `.value`.
1148
+ - **Restore** — on connect, **first** among the client bindings, so a
1149
+ `reactive_show` section, `reactive_on_complete` (armed, never fired),
1150
+ `reactive_filter` and a `reactive_compute` root all read the restored values
1151
+ on first paint — no synthetic events. A morph or broadcast re-render is
1152
+ server truth and is **never** re-restored.
1153
+ - **`restore: :blank`** (default) — a draft value lands only in a control the
1154
+ server rendered **blank**, so a 422 re-render's submitted values beat an
1155
+ older draft. `restore: :always` lets the draft win.
1156
+ - **Clear** — a successful `turbo:submit-end` of the form that contains the
1157
+ root, `ttl` expiry (checked on read; default `7.days`), or `js.persist_clear`.
1158
+ A successful *reactive* action does **not** clear on its own — chain
1159
+ `reply.js(js.persist_clear)` from the action when it should.
1160
+ - **`fields:`** narrows the set to declared names (scope-aware symbols):
1161
+ `reactive_persist(key: "k", fields: %i[name bio])`.
1162
+ - **Never persisted**: `type=hidden/file/password/submit/button/reset/image`,
1163
+ anything carrying `reactive_persist_skip`, a nested reactive root's controls,
1164
+ and rich-text/`contenteditable` editors (they aren't `input/select/textarea`).
1165
+ `autocomplete="off"` is **not** an implicit skip — a wizard often sets it
1166
+ form-wide. **Honeypots must opt out** (`reactive_persist_skip`) or sit outside
1167
+ the root: an invisible-captcha text input looks like any other field.
1168
+ - **State bag** — `js.persist_state(step: 2)` merges a flat hash of scalars
1169
+ into the same draft (a wizard's current step). On restore the root carries
1170
+ `data-reactive-persist-state='{"step":2}'` and dispatches a bubbling
1171
+ `reactive:persist-restored` event (`detail: { key, fields, state }`) — the
1172
+ hook for your own wizard controller to jump to the saved step.
1173
+ - **Storage failures are silent** — a private window, a quota error or blocked
1174
+ storage degrades to "no draft". Under `Phlex::Reactive.debug` the controller
1175
+ prints one `console.info` naming the failure so a dev sees why nothing came
1176
+ back.
1177
+
1178
+ Threat model: values are replayed via `.value`/`.checked` only (never HTML) — a
1179
+ tampered draft can only fill what the user could type. PII sits in this
1180
+ browser's `localStorage` for `ttl`; the submit-clear and `ttl` are the
1181
+ shared-computer mitigation. `persist_state`/`persist_clear` are **actor-only**
1182
+ ops (refused by `broadcast_to(js:)`). See the [security page](docs/security.md).
1183
+
1106
1184
  ### Client-side computes (`reactive_compute` + `reactive_text`)
1107
1185
 
1108
1186
  Some math should feel instant with **no round trip** — a NEW, unsaved record's
@@ -1055,6 +1055,215 @@ 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, 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
+
1058
1267
  // The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors
1059
1268
  // Phlex::Reactive::JS's vocabulary; an op name not in this map is
1060
1269
  // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
@@ -1131,6 +1340,13 @@ const CLIENT_OPS = Object.freeze({
1131
1340
  // an actor reply's stream from a broadcast's, and reply.js legitimately
1132
1341
  // carries this op.
1133
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),
1134
1350
  })
1135
1351
 
1136
1352
  // The form a submit op commits (issue #226), in order: the target itself when
@@ -1675,6 +1891,17 @@ export default class extends Controller {
1675
1891
  // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1676
1892
  // held for teardown.
1677
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
1678
1905
 
1679
1906
  // Mark that a reactive controller actually connected, so the registration
1680
1907
  // guard above knows the controller was registered (issue #26 part 2).
@@ -1716,6 +1943,18 @@ export default class extends Controller {
1716
1943
  this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1717
1944
  }
1718
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
+
1719
1958
  // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
1720
1959
  // reactive_field(dirty:)), so a component that never uses it pays nothing (no
1721
1960
  // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
@@ -1895,6 +2134,10 @@ export default class extends Controller {
1895
2134
  // leading-edge timer holds no pending POST, but leaving it running would leak
1896
2135
  // it past the element's life.
1897
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()
1898
2141
  this.#clearAllDebounces()
1899
2142
  this.#clearAllThrottles()
1900
2143
  this.#teardownDirtyTracking()
@@ -3978,6 +4221,87 @@ export default class extends Controller {
3978
4221
 
3979
4222
  // Remove the show-sync listeners on disconnect, so a stray event after a
3980
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
+
3981
4305
  #teardownShowSync() {
3982
4306
  if (!this.#boundSyncShow) return
3983
4307
  this.element.removeEventListener?.("input", this.#boundSyncShow)