phlex-reactive 0.13.4 → 0.14.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1050 -630
  3. data/README.md +561 -14
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +71 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +17 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +20 -2
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -0,0 +1,663 @@
1
+ // Client-only drafts (issue #239) — reactive_persist. A FEATURE MODULE of the
2
+ // reactive controller (issue #275): the core imports it only when a root on
3
+ // the page declares data-reactive-persist, or when a persist_state /
4
+ // persist_clear client op runs.
5
+ //
6
+ // A root that declares
7
+ // data-reactive-persist='{"key","ttl","debounce"[,"fields"][,"restore"]}'
8
+ // keeps a localStorage draft of every persistable OWNED control (write on
9
+ // input/change, restore on connect, clear on a successful submit / TTL / the
10
+ // persist_clear op). The storage layer is MODULE-LEVEL and keyed on the root
11
+ // element — the per-root connect/write path and the persist_state /
12
+ // persist_clear client ops (which receive only the element) share it. Every
13
+ // storage access is try/catch'd: a private window, a quota error or blocked
14
+ // storage degrades to "no draft", never a thrown bootstrap. Nothing here
15
+ // leaves the browser: no token, no POST, and phlex-reactive never writes
16
+ // markup into the DOM: native controls are replayed via
17
+ // .value/.checked/.selected, a bare [contenteditable] via textContent, and a
18
+ // rich editor (lexxy-editor, trix-editor — issue #241) through its OWN
19
+ // `value` setter, the same sanitizing import path a paste takes. Never
20
+ // innerHTML.
21
+ //
22
+ // The feature contract (reactive_controller.js "Feature modules"):
23
+ // install(shared) once, when the module loads
24
+ // connect(controller, core, morphed, pending) per root connection
25
+ // disconnect(controller, core) before the core's teardown
26
+ // abandon(root, pending) the root left before connect
27
+ // `core` is the controller's handle for features: core.emit(name, detail)
28
+ // raw-dispatches a lifecycle event from the root; core.reseed() re-runs the
29
+ // connect-time seeds that read field values (dirty baseline, show bindings,
30
+ // on-complete arming, filter, tags, nested JSON, compute).
31
+ //
32
+ // TIMING. On the first root of a page that needs it, this module is still on
33
+ // its way when the controller connects, so the restore runs a moment AFTER
34
+ // connect() — and the connect-time seeds, which ran on the server's values,
35
+ // are re-run (core.reseed()). On any later root the module is loaded and the
36
+ // restore runs inside connect() as it always did.
37
+ //
38
+ // While a root waits for this module:
39
+ // * it writes no draft, and its action requests are held back (they would
40
+ // post the server's blanks) — the core's side of it;
41
+ // * the core records, in `pending`, that the user edited a field and that
42
+ // the form was submitted successfully; connect() drafts the edit, or
43
+ // forgets the draft instead of restoring it;
44
+ // * a persist_state / persist_clear op waits in pending.ops — its snapshot
45
+ // would overwrite the draft with those blanks.
46
+ // A root that leaves before the module arrives is handed to abandon().
47
+
48
+ // The core's shared state (install()): shared.waiting maps a root to
49
+ // { persist: pending } while that root waits for this module.
50
+ let shared
51
+
52
+ export function install(given) {
53
+ shared = given
54
+ }
55
+
56
+ const PERSIST_VERSION = 1
57
+ const PERSIST_PREFIX = "phlex-reactive:persist:"
58
+ // Never persisted regardless of author intent: no server default to restore
59
+ // into (hidden, file), secrets (password), and non-value controls.
60
+ const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
61
+ const PERSIST_NO_VALUE = Symbol("persist-no-value")
62
+ const PERSIST_STATE_ATTR = "data-reactive-persist-state"
63
+ // The editor query the core's #collectFields reads (minus the [name] guard —
64
+ // an editor's name may live on its IDL `name` getter: Trix's `input=`-paired
65
+ // hidden input). The core holds the same two constants (EDITOR_SELECTOR,
66
+ // EDITOR_TAGS); a feature never imports the core, so they are repeated here
67
+ // and spec/javascript/reactive_persist.test.js pins that they stay equal.
68
+ export const PERSIST_EDITOR_SELECTOR =
69
+ ":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
70
+ export const PERSIST_EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
71
+ // An editor's own chrome: Lexxy renders its toolbar (a `lexxy-code-language`
72
+ // select, the link dialog's `href` input) INSIDE <lexxy-editor>, Trix as a
73
+ // sibling <trix-toolbar>. Those are named native controls that are not the
74
+ // user's fields — never drafted, never restored into.
75
+ const PERSIST_EDITOR_CHROME = "lexxy-editor, trix-editor, trix-toolbar"
76
+ // The editors' own bubbling change events — the keystroke signal for the
77
+ // draft write, since neither lets its contenteditable's native `input` bubble.
78
+ const PERSIST_EDITOR_CHANGE_EVENTS = ["lexxy:change", "trix-change"]
79
+ // "Is this editor empty" when it exposes no predicate of its own (Lexxy's
80
+ // `isEmpty`, Trix's `editor.getDocument().isEmpty()`): Lexxy's own empty
81
+ // list plus the Trix / contenteditable empties. Exact strings, no HTML
82
+ // parsing — an attachment-only body stays non-blank.
83
+ const PERSIST_EMPTY_HTML = new Set(["", "<p></p>", "<p><br></p>", "<div><br></div>"])
84
+ // Once-per-root guards: a malformed payload warns once; the storage-failure
85
+ // and editor-restore dev notes (debug mode only) print once each.
86
+ const persistPayloadWarned = new WeakSet()
87
+ const persistFailureNoted = new WeakSet()
88
+ const persistEditorNoted = new WeakSet()
89
+
90
+ // The root's parsed payload, or null (undeclared / malformed → warned once).
91
+ // A control-level "off" (reactive_persist_skip) is never a root payload.
92
+ function persistPayload(root) {
93
+ const raw = root?.getAttribute?.("data-reactive-persist")
94
+ if (!raw || raw === "off") return null
95
+ try {
96
+ const payload = JSON.parse(raw)
97
+ if (payload && typeof payload === "object" && typeof payload.key === "string" && payload.key !== "") return payload
98
+ } catch {
99
+ // fall through to the warn
100
+ }
101
+ if (!persistPayloadWarned.has(root)) {
102
+ persistPayloadWarned.add(root)
103
+ console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(raw)} — persistence disabled`)
104
+ }
105
+ return null
106
+ }
107
+
108
+ function persistStorage() {
109
+ try {
110
+ return typeof localStorage === "undefined" ? null : localStorage
111
+ } catch {
112
+ return null // the accessor itself can throw (blocked site data)
113
+ }
114
+ }
115
+
116
+ // The dev lens for "why did nothing come back": ONLY under data-reactive-debug
117
+ // (Phlex::Reactive.debug), once per root — production stays silent.
118
+ function persistNoteFailure(root, error) {
119
+ if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistFailureNoted.has(root)) return
120
+ persistFailureNoted.add(root)
121
+ console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${error?.name ?? error})`)
122
+ }
123
+
124
+ function persistKeyFor(payload) {
125
+ return PERSIST_PREFIX + payload.key
126
+ }
127
+
128
+ // Read + validate the draft: null when absent, unparsable, another schema
129
+ // version, or expired (an expired draft is REMOVED on read). Returns
130
+ // { fields, state } with state null when the draft carries none.
131
+ function persistRead(root, payload) {
132
+ const store = persistStorage()
133
+ if (!store) return null
134
+ let raw
135
+ try {
136
+ raw = store.getItem(persistKeyFor(payload))
137
+ } catch (error) {
138
+ persistNoteFailure(root, error)
139
+ return null
140
+ }
141
+ if (!raw) return null
142
+ let draft
143
+ try {
144
+ draft = JSON.parse(raw)
145
+ } catch {
146
+ return null
147
+ }
148
+ if (!draft || typeof draft !== "object" || draft.v !== PERSIST_VERSION) return null
149
+ const ttlMs = Number(payload.ttl) * 1000
150
+ if (!(Number(draft.savedAt) + ttlMs > Date.now())) {
151
+ persistRemove(root, payload)
152
+ return null
153
+ }
154
+ const fields = draft.fields && typeof draft.fields === "object" ? draft.fields : {}
155
+ const state = draft.state && typeof draft.state === "object" ? draft.state : null
156
+ return { fields, state }
157
+ }
158
+
159
+ function persistWrite(root, payload, { fields, state }) {
160
+ const store = persistStorage()
161
+ if (!store) return false
162
+ const draft = { v: PERSIST_VERSION, savedAt: Date.now(), fields }
163
+ if (state) draft.state = state
164
+ try {
165
+ store.setItem(persistKeyFor(payload), JSON.stringify(draft))
166
+ return true
167
+ } catch (error) {
168
+ persistNoteFailure(root, error)
169
+ return false
170
+ }
171
+ }
172
+
173
+ function persistRemove(root, payload) {
174
+ root?.removeAttribute?.(PERSIST_STATE_ATTR)
175
+ const store = persistStorage()
176
+ if (!store) return
177
+ try {
178
+ store.removeItem(persistKeyFor(payload))
179
+ } catch (error) {
180
+ persistNoteFailure(root, error)
181
+ }
182
+ }
183
+
184
+ // The persistable controls this root OWNS (#15: a nested reactive root's
185
+ // controls are its own), minus the excluded types, the reactive_persist_skip
186
+ // marker, and — when `fields` narrows the set — any undeclared name. Each
187
+ // entry is { el, name, kind } with kind "native" (input/select/textarea),
188
+ // "editor" (lexxy-editor / trix-editor) or "contenteditable" (a bare named
189
+ // editable element) — issue #241.
190
+ function persistControls(root, payload) {
191
+ const allow = Array.isArray(payload.fields) ? new Set(payload.fields) : null
192
+ const out = []
193
+ const owned = (el) =>
194
+ el.closest('[data-controller~="reactive"]') === root && el.getAttribute("data-reactive-persist") !== "off"
195
+ for (const el of root.querySelectorAll("input[name], select[name], textarea[name]")) {
196
+ if (!owned(el) || PERSIST_EXCLUDED_TYPES.has(el.type) || el.closest(PERSIST_EDITOR_CHROME)) continue
197
+ if (allow && !allow.has(el.name)) continue
198
+ out.push({ el, name: el.name, kind: "native" })
199
+ }
200
+ for (const el of root.querySelectorAll(PERSIST_EDITOR_SELECTOR)) {
201
+ if (!owned(el)) continue
202
+ const name = persistEditorName(el)
203
+ if (!name || (allow && !allow.has(name))) continue
204
+ out.push({ el, name, kind: PERSIST_EDITOR_TAGS.has(el.localName) ? "editor" : "contenteditable" })
205
+ }
206
+ return out
207
+ }
208
+
209
+ // The attribute first (Lexxy, a bare contenteditable — which has no `name`
210
+ // IDL property at all), then the IDL getter (Trix resolves it through its
211
+ // `input=`-paired hidden input, which is itself excluded as type=hidden).
212
+ function persistEditorName(el) {
213
+ return el.getAttribute("name") || (typeof el.name === "string" && el.name) || null
214
+ }
215
+
216
+ // An editor is READY once its custom element has upgraded and connected: only
217
+ // then does it expose the string `value` accessor (Lexxy's setter throws
218
+ // before connectedCallback created its editor; Trix's discards the value).
219
+ function persistEditorReady(el) {
220
+ return typeof el.value === "string"
221
+ }
222
+
223
+ // Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
224
+ // `editor.getDocument().isEmpty()`), else the exact-string fallback. An
225
+ // attachment-only server body is therefore NON-blank and never overwritten.
226
+ function persistEditorBlank(el) {
227
+ if (typeof el.isEmpty === "boolean") return el.isEmpty
228
+ const doc = el.editor?.getDocument?.()
229
+ if (typeof doc?.isEmpty === "function") return doc.isEmpty()
230
+ return PERSIST_EMPTY_HTML.has(el.value.trim())
231
+ }
232
+
233
+ // The editor-restore dev lens: ONLY under data-reactive-debug, once per root.
234
+ function persistNoteEditorFailure(root, name, error) {
235
+ if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistEditorNoted.has(root)) return
236
+ persistEditorNoted.add(root)
237
+ console.info(
238
+ `[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(name)} — ${error?.message ?? error}`,
239
+ )
240
+ }
241
+
242
+ function persistSelectMultiple(el) {
243
+ return el.tagName === "SELECT" && el.multiple
244
+ }
245
+
246
+ // How many controls can contribute a value under each `[]` name, counted the
247
+ // way persistSnapshot fills the slot — a radio is the exception there and here.
248
+ // A group of ONE is unambiguous: its single entry can only have come from that
249
+ // control.
250
+ function persistGroupSizes(controls) {
251
+ const sizes = new Map()
252
+ for (const { el, name } of controls) {
253
+ if (el.type === "radio" || !String(name).endsWith("[]")) continue
254
+ sizes.set(name, (sizes.get(name) ?? 0) + 1)
255
+ }
256
+ return sizes
257
+ }
258
+
259
+ // The value a control that cannot pick its own entry out of a list may take.
260
+ // A list belongs to a `[]` group, and in a group of two or more nothing says
261
+ // which entry came from which control — restoring it would paste
262
+ // "freeform,news" into a text field. A group of ONE has no such ambiguity, and
263
+ // refusing it would silently drop the draft of a plain field whose name merely
264
+ // ends in `[]` (a list JS maintains), which worked before groups existed.
265
+ // Returns PERSIST_NO_VALUE when the control must keep what the server rendered.
266
+ function persistGenericValue(value, name, sizes) {
267
+ if (!Array.isArray(value)) return value
268
+ if (sizes.get(name) !== 1 || value.length !== 1) return PERSIST_NO_VALUE
269
+
270
+ return value[0]
271
+ }
272
+
273
+ // Snapshot the owned controls: radio → the checked value (null when the group
274
+ // has none, so a restore leaves it alone), checkbox → checked, multi-select →
275
+ // the selected values, a rich editor → its serialized `value` (omitted while
276
+ // the element hasn't upgraded — never a phantom ""), a bare contenteditable →
277
+ // its textContent, else .value. Mirrors the core's #collectFields reads.
278
+ function persistSnapshot(root, payload) {
279
+ const fields = {}
280
+ // A `[]` name is a group for every control that can contribute a value —
281
+ // checkboxes, selects, text inputs, editors and contenteditables all append
282
+ // to one array, mirroring #collectFields. The one exception is a RADIO group,
283
+ // which means "pick one" and keeps its single value with or without the
284
+ // suffix, exactly as the collector treats it.
285
+ //
286
+ // Reading the slot without this (fields[name] ?? []) breaks as soon as a
287
+ // non-checkbox shares the group's name: a text input or an editor leaves a
288
+ // string there, `.push` on it throws inside the draft write, and that write
289
+ // is swallowed — the root then persists nothing at all, silently. Editors
290
+ // are collected AFTER the native controls, so they always land last.
291
+ const groupSlot = (name) => {
292
+ const existing = fields[name]
293
+ return Array.isArray(existing) ? existing : (fields[name] = [])
294
+ }
295
+ for (const { el, name, kind } of persistControls(root, payload)) {
296
+ const group = String(name).endsWith("[]")
297
+ if (kind === "editor") {
298
+ if (persistEditorReady(el)) {
299
+ if (group) groupSlot(name).push(el.value)
300
+ else fields[name] = el.value
301
+ }
302
+ } else if (kind === "contenteditable") {
303
+ const text = el.textContent ?? ""
304
+ if (group) groupSlot(name).push(text)
305
+ else fields[name] = text
306
+ } else if (el.type === "radio") {
307
+ if (el.checked) fields[name] = el.value
308
+ else if (!Object.hasOwn(fields, name)) fields[name] = null
309
+ } else if (el.type === "checkbox") {
310
+ // A `[]` group drafts the list of ticked values, mirroring #collectFields
311
+ // (issue #258). Without this the boxes overwrote each other and the draft
312
+ // held one boolean, which the restore then applied to every box of the
313
+ // group. A lone checkbox keeps the boolean it has always been.
314
+ if (group) {
315
+ const slot = groupSlot(name)
316
+ if (el.checked) slot.push(el.value)
317
+ } else {
318
+ fields[name] = el.checked
319
+ }
320
+ } else if (persistSelectMultiple(el)) {
321
+ const selected = [...el.options].filter((o) => o.selected).map((o) => o.value)
322
+ if (group) groupSlot(name).push(...selected)
323
+ else fields[name] = selected
324
+ } else if (group) {
325
+ groupSlot(name).push(el.value)
326
+ } else {
327
+ fields[name] = el.value
328
+ }
329
+ }
330
+ return fields
331
+ }
332
+
333
+ // Replay the draft into the owned controls. Default (restore: blank): a
334
+ // control the server rendered NON-BLANK keeps its value — a 422 re-render's
335
+ // submitted values beat an older draft. restore: "always" lets the draft win.
336
+ // Values land via .value/.checked/.selected, textContent, or the editor's own
337
+ // `value` setter — never HTML written by us.
338
+ function persistApply(root, payload, fields) {
339
+ const always = payload.restore === "always"
340
+ const controls = persistControls(root, payload)
341
+ // Which group names the SERVER rendered with a box already ticked. Computed
342
+ // BEFORE the loop on purpose: the loop writes `checked`, so asking this
343
+ // question from inside it would read THIS restore's own work — the first box
344
+ // it ticks makes every later box of the same group look server-rendered, and
345
+ // a draft of two values comes back as one. The radio branch below asks the
346
+ // same question inline and stays correct only because a radio group holds a
347
+ // single value.
348
+ const groupSizes = persistGroupSizes(controls)
349
+ const serverTicked = new Set()
350
+ for (const control of controls) {
351
+ if (control.kind === "native" && control.el.type === "checkbox" && control.el.checked) {
352
+ serverTicked.add(control.name)
353
+ }
354
+ }
355
+ for (const { el, name, kind } of controls) {
356
+ if (!Object.hasOwn(fields, name)) continue
357
+ let value = fields[name]
358
+ if (value === null || value === undefined) continue
359
+ // A multi-select reads a list by matching option values, which is only
360
+ // sound when the list is ITS list. In a group with another contributor the
361
+ // entries are mixed, and a text value that happens to equal an option
362
+ // would select it — measured, a draft of ["blue","freitext"] from a select
363
+ // plus a text field selected both options. `?? 0` because a name without
364
+ // the suffix is not in the map at all, and a plain `<select multiple
365
+ // name="colors">` must keep restoring.
366
+ if (Array.isArray(value) && persistSelectMultiple(el) && (groupSizes.get(name) ?? 0) > 1) continue
367
+
368
+ // An array belongs to a `[]` group, and only a control that can pick ITS
369
+ // entry out of the list may read it: a checkbox matches by value, a
370
+ // multi-select by option. Everything else — editors, contenteditables,
371
+ // text inputs — keeps what the server rendered, because the list does not
372
+ // record which entry came from which control. This sits ABOVE the branch
373
+ // chain on purpose: below the editor branch it would never fire for the
374
+ // very controls that land last in the snapshot.
375
+ if (Array.isArray(value) && !(el.type === "checkbox" || persistSelectMultiple(el))) {
376
+ value = persistGenericValue(value, name, groupSizes)
377
+ if (value === PERSIST_NO_VALUE) continue
378
+ }
379
+ // The mirror, for a draft written BEFORE a group was drafted as a list
380
+ // (#258): there `features[]` held ONE boolean, and applying it here ticks
381
+ // every box of the group — precisely the state this fix removes, for as
382
+ // long as the draft lives (default ttl 7 days). A group key that is not a
383
+ // list is stale by definition, so the control keeps what the server
384
+ // rendered and the next snapshot overwrites the key. It reads for a
385
+ // multi-select too: 0.13.2 wrote last-writer-wins per name, so a checkbox
386
+ // in a mixed group could leave its boolean under the select's name, and
387
+ // under `restore: "always"` the select would then deselect everything —
388
+ // `wanted` being Set{"true"} matches no option. Asking for the `[]` suffix
389
+ // is what leaves a lone `gift` checkbox on the boolean it has always held —
390
+ // and scoping the rule to the one key whose meaning changed is why
391
+ // PERSIST_VERSION stays at 1: bumping it would also throw away the drafted
392
+ // prose of every form that has no checkbox group at all.
393
+ if ((el.type === "checkbox" || persistSelectMultiple(el)) && String(name).endsWith("[]") && !Array.isArray(value)) {
394
+ continue
395
+ }
396
+ if (kind === "editor") {
397
+ persistApplyEditor(root, el, name, value, always)
398
+ } else if (kind === "contenteditable") {
399
+ if (!always && (el.textContent ?? "").trim() !== "") continue
400
+ el.textContent = String(value)
401
+ } else if (el.type === "radio") {
402
+ if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
403
+ el.checked = el.value === String(value)
404
+ } else if (el.type === "checkbox" && Array.isArray(value)) {
405
+ // A drafted group ticks exactly the boxes it held. One box the SERVER
406
+ // rendered ticked means it had a say, and the draft yields for the whole
407
+ // group.
408
+ if (!always && serverTicked.has(name)) continue
409
+ el.checked = value.map(String).includes(el.value)
410
+ } else if (el.type === "checkbox") {
411
+ if (!always && el.checked) continue
412
+ el.checked = Boolean(value)
413
+ } else if (persistSelectMultiple(el)) {
414
+ if (!always && [...el.options].some((o) => o.selected)) continue
415
+ const wanted = new Set((Array.isArray(value) ? value : [value]).map(String))
416
+ for (const o of el.options) o.selected = wanted.has(o.value)
417
+ } else {
418
+ if (!always && el.value !== "") continue
419
+ el.value = String(value)
420
+ }
421
+ }
422
+ persistDeferEditors(root, payload, fields)
423
+ }
424
+
425
+ // A ready editor takes the value through its own setter; the setter is the
426
+ // editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
427
+ // sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
428
+ function persistApplyEditor(root, el, name, value, always) {
429
+ // A list never reaches here: both callers resolve it through
430
+ // persistGenericValue first — the group of two or more has no mapping back to
431
+ // this editor, the group of one does. Belt and braces, because this is the
432
+ // one apply path with a second entry point.
433
+ if (Array.isArray(value)) return
434
+ if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
435
+ if (!always && !persistEditorBlank(el)) return
436
+ try {
437
+ el.value = String(value)
438
+ } catch (error) {
439
+ persistNoteEditorFailure(root, name, error)
440
+ }
441
+ }
442
+
443
+ // An editor whose custom element is not defined yet (Trix defines its elements
444
+ // in a setTimeout after load; a lazily imported Lexxy) cannot take a value now.
445
+ // Re-apply per TAG once it is defined — re-querying the controls (the upgrade
446
+ // may replace the node) and re-checking restore: blank at that moment — unless
447
+ // the root has left the document meanwhile.
448
+ function persistDeferEditors(root, payload, fields) {
449
+ const registry = globalThis.customElements
450
+ if (typeof registry?.whenDefined !== "function") return
451
+ const pending = new Set()
452
+ for (const el of root.querySelectorAll("lexxy-editor, trix-editor")) {
453
+ if (!persistEditorReady(el) && !registry.get?.(el.localName)) pending.add(el.localName)
454
+ }
455
+ const always = payload.restore === "always"
456
+ for (const tag of pending) {
457
+ registry.whenDefined(tag).then(() => {
458
+ if (!root.isConnected) return
459
+ const deferred = persistControls(root, payload)
460
+ const sizes = persistGroupSizes(deferred)
461
+ for (const { el, name, kind } of deferred) {
462
+ if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
463
+ const value = persistGenericValue(fields[name], name, sizes)
464
+ if (value === null || value === undefined || value === PERSIST_NO_VALUE) continue
465
+ persistApplyEditor(root, el, name, value, always)
466
+ }
467
+ })
468
+ }
469
+ }
470
+
471
+ // The persist_state op body: merge a FLAT bag into the root's draft (re-
472
+ // snapshotting the fields so the write is whole) and mirror it on the root.
473
+ // A root without reactive_persist is a call-site bug — warn and skip.
474
+ // `restored` false: the root never restored (it left before this module
475
+ // arrived), so its controls hold the server's blanks — keep the drafted
476
+ // fields and merge the bag only.
477
+ function persistWriteState(root, state, restored = true) {
478
+ const payload = persistPayload(root)
479
+ if (!payload) {
480
+ console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped")
481
+ return
482
+ }
483
+ if (!state || typeof state !== "object") return
484
+ const current = persistRead(root, payload)
485
+ const merged = { ...(current?.state ?? {}), ...state }
486
+ const fields = restored ? persistSnapshot(root, payload) : (current?.fields ?? {})
487
+ if (persistWrite(root, payload, { fields, state: merged })) {
488
+ root.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(merged))
489
+ }
490
+ }
491
+
492
+ function persistClearRoot(root) {
493
+ const payload = persistPayload(root)
494
+ if (payload) persistRemove(root, payload)
495
+ }
496
+
497
+ // --- Per-root wiring -------------------------------------------------------
498
+
499
+ // controller -> the closure that flushes and unwires the connection this
500
+ // feature is wired to. Its timer and handlers are locals of connect(), not a
501
+ // record's fields: the minifier renames a local, never a property (#310).
502
+ const wired = new WeakMap()
503
+
504
+ // Restore the draft into the owned controls, expose the state bag, announce,
505
+ // then arm the write listeners. The restore reads ONCE and never writes back:
506
+ // nothing is wired until it has completed, so no listener can clobber the
507
+ // draft with the server's blanks. The submit-end listener is DOCUMENT-level
508
+ // (the event fires on the form, which is usually an ANCESTOR of this root)
509
+ // and gated on the form containing this root.
510
+ //
511
+ // `pending` is what the core recorded while this root waited for the module
512
+ // (undefined when it never had to): see TIMING at the top.
513
+ export function connect(controller, core, _morphed, pending) {
514
+ const root = controller.element
515
+ const payload = persistPayload(root)
516
+ if (!payload) return
517
+ // Submitted successfully while we were on our way: the draft is done with.
518
+ if (pending?.submitted) persistRemove(root, payload)
519
+ const draft = persistRead(root, payload)
520
+ if (draft) {
521
+ persistApply(root, payload, draft.fields)
522
+ if (draft.state) root.setAttribute?.(PERSIST_STATE_ATTR, JSON.stringify(draft.state))
523
+ core.emit("reactive:persist-restored", { key: payload.key, fields: draft.fields, state: draft.state ?? {} })
524
+ // If the connect-time seeds ran before this restore, they ran on the
525
+ // server's values; they read fields.
526
+ core.reseed()
527
+ }
528
+
529
+ // ONE pending keystroke write per root (a snapshot is a full pass, so
530
+ // per-field timers would only multiply writes).
531
+ let timer = null
532
+ const cancel = () => {
533
+ if (timer !== null) {
534
+ clearTimeout(timer)
535
+ timer = null
536
+ }
537
+ }
538
+ // Snapshot every persistable owned control and write. Re-reads the current
539
+ // draft first so a state bag written by persist_state survives the write
540
+ // (the bag lives in storage, not on the connection — the op has none).
541
+ const writeNow = () => {
542
+ cancel()
543
+ const current = persistRead(root, payload)
544
+ persistWrite(root, payload, { fields: persistSnapshot(root, payload), state: current?.state ?? null })
545
+ }
546
+ // Trailing-edge debounce for keystrokes.
547
+ const onInput = () => {
548
+ const ms = Number(payload.debounce) || 0
549
+ if (ms <= 0) return writeNow()
550
+ if (timer !== null) clearTimeout(timer)
551
+ timer = setTimeout(() => {
552
+ timer = null
553
+ writeNow()
554
+ }, ms)
555
+ }
556
+ // A SUCCESSFUL Turbo form submission of the form that owns this root
557
+ // forgets the draft. tagName (not instanceof) so a cross-realm form counts.
558
+ const onSubmitEnd = (event) => {
559
+ if (!event?.detail?.success) return
560
+ const form = event.target
561
+ if (form?.tagName !== "FORM" || typeof form.contains !== "function") return
562
+ if (!form.contains(root)) return
563
+ // Drop a pending keystroke write too — the disconnect flush that follows
564
+ // Turbo's redirect visit would otherwise resurrect the just-cleared draft.
565
+ cancel()
566
+ persistRemove(root, payload)
567
+ }
568
+ // Rich editors (#241): Lexical and Trix swallow the native `input` of
569
+ // their contenteditable, so their own bubbling change events are the
570
+ // keystroke signal — same trailing-edge debounce as `input`.
571
+ const inputs = ["input", ...PERSIST_EDITOR_CHANGE_EVENTS]
572
+ for (const event of inputs) root.addEventListener?.(event, onInput)
573
+ root.addEventListener?.("change", writeNow)
574
+ document.addEventListener?.("turbo:submit-end", onSubmitEnd)
575
+ wired.set(controller, () => {
576
+ if (timer !== null) writeNow()
577
+ for (const event of inputs) root.removeEventListener?.(event, onInput)
578
+ root.removeEventListener?.("change", writeNow)
579
+ document.removeEventListener?.("turbo:submit-end", onSubmitEnd)
580
+ })
581
+
582
+ // An edit made while we were on our way had no listener to draft it: draft
583
+ // it now, with everything the restore just filled in around it.
584
+ if (pending?.edited && !pending.submitted) writeNow()
585
+ for (const op of pending?.ops ?? []) op(true)
586
+ }
587
+
588
+ // The root left before this module could connect to it. Nothing was restored
589
+ // into it, so its controls hold what the server rendered plus whatever the
590
+ // user did: forget the draft if the form was submitted; otherwise keep the
591
+ // drafted fields, add the ones the user CHANGED, and run the ops that waited.
592
+ export function abandon(root, pending) {
593
+ if (!pending) return
594
+ const payload = persistPayload(root)
595
+ if (!payload) return
596
+ if (pending.submitted) return persistRemove(root, payload)
597
+ if (pending.edited) {
598
+ const current = persistRead(root, payload)
599
+ const fields = { ...current?.fields }
600
+ const snapshot = persistSnapshot(root, payload)
601
+ for (const name of changedNames(root, payload)) {
602
+ if (Object.hasOwn(snapshot, name)) fields[name] = snapshot[name]
603
+ }
604
+ persistWrite(root, payload, { fields, state: current?.state ?? null })
605
+ }
606
+ for (const op of pending.ops ?? []) op(false)
607
+ }
608
+
609
+ // The names of the owned controls the user changed on a root that never
610
+ // restored: those whose live state differs from what the SERVER rendered (the
611
+ // DOM keeps that as defaultValue / defaultChecked / the `selected` attribute), so a
612
+ // server-prefilled value is never mistaken for an edit, and an emptied field
613
+ // or an unticked box counts as one. An editor has no such default: it counts
614
+ // once it holds anything.
615
+ function changedNames(root, payload) {
616
+ const names = new Set()
617
+ for (const { el, name, kind } of persistControls(root, payload)) {
618
+ const changed =
619
+ kind === "editor"
620
+ ? persistEditorReady(el) && !persistEditorBlank(el)
621
+ : kind === "contenteditable"
622
+ ? (el.textContent ?? "").trim() !== ""
623
+ : el.type === "checkbox" || el.type === "radio"
624
+ ? el.checked !== el.defaultChecked
625
+ : el.tagName === "SELECT"
626
+ ? [...el.options].some((option) => option.selected !== option.hasAttribute("selected"))
627
+ : el.value !== el.defaultValue
628
+ if (changed) names.add(name)
629
+ }
630
+ return names
631
+ }
632
+
633
+ // Flush a pending write while the fields are still readable (Turbo
634
+ // disconnects before leaving the page — a fast visit otherwise loses the last
635
+ // keystrokes), then drop every listener: the closure connect() stored does both.
636
+ export function disconnect(controller) {
637
+ const off = wired.get(controller)
638
+ wired.delete(controller)
639
+ off?.()
640
+ }
641
+
642
+ // --- The persist_state / persist_clear client ops --------------------------
643
+
644
+ // A root that is still waiting for this module shows the server's blanks: an
645
+ // op that snapshots or clears it now would destroy the draft the restore is
646
+ // about to read. Such an op waits in the root's pending record and runs after
647
+ // the restore (connect) — or, if the root leaves first, without one (abandon).
648
+ // Any other root runs it at once.
649
+ function whenRestored(root, op) {
650
+ const pending = shared?.waiting.get(root)?.persist
651
+ // (No payload: connect() will not restore this root, so nothing would ever
652
+ // run a queued op — a morph can take the marker away while we load.)
653
+ if (!pending || !persistPayload(root)) return op(true)
654
+ ;(pending.ops ??= []).push(op)
655
+ }
656
+
657
+ export function writeState(root, state) {
658
+ whenRestored(root, (restored) => persistWriteState(root, state, restored))
659
+ }
660
+
661
+ export function clearRoot(root) {
662
+ whenRestored(root, () => persistClearRoot(root))
663
+ }