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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +79 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +324 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +85 -0
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/streamable.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a9ece1128621af6ad4b9b8e249c1005bcfd063e0aa70235800292f156ac4419
|
|
4
|
+
data.tar.gz: f8613e2ec77fb55d1f9b5b0b30719382ef5e52c2a34d1ff6bcc29f9a017bafc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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),
|
|
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)
|