phlex-reactive 0.12.6 → 0.13.1
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 +127 -2
- data/README.md +268 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +1 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +1 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +139 -18
- 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/collections.rb +144 -0
- data/lib/phlex/reactive/component/helpers.rb +5 -0
- data/lib/phlex/reactive/pending.rb +337 -0
- data/lib/phlex/reactive/reply.rb +37 -0
- data/lib/phlex/reactive/response.rb +109 -68
- data/lib/phlex/reactive/settle.rb +170 -0
- data/lib/phlex/reactive/settles.rb +272 -0
- data/lib/phlex/reactive/streamable.rb +146 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +35 -0
- metadata +5 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
1
|
+
var t=new Map;function n(e,r){t.set(e,r)}function i(e){return t.get(e)}function o(){t.clear()}export{o as __resetConfirmPredicateRegistryForTest,i as confirmPredicate,n as setConfirmPredicate};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=64DE097A0F67E08564756E2164756E21
|
|
4
4
|
//# sourceMappingURL=confirm_predicate.min.js.map
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
"// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
|
|
8
|
-
"debugId": "
|
|
8
|
+
"debugId": "64DE097A0F67E08564756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
function
|
|
1
|
+
function u(t=document){let e=Array.from(t.querySelectorAll('[data-controller~="reactive"]'));return e.map((a)=>c(a,e))}function w(t=document){let e=u(t),a=globalThis.console;if(a&&typeof a.log==="function")a.log(`[phlex-reactive] ${e.length} reactive root(s)`);if(a&&typeof a.table==="function")a.table(e.map((r)=>({id:r.id,component:r.component??(r.opaque?"(opaque token)":null),actions:r.triggers.map((i)=>i.action).join(", "),fields:r.fields.join(", "),computes:r.computes.join(", ")})));return e}function c(t,e){let a=t.getAttribute("data-reactive-token-value"),r=s(a);return{id:t.id||null,component:r.opaque?null:r.c??null,gid:r.gid??null,stateKeys:r.s?Object.keys(r.s):[],tokenVersion:r.v??null,opaque:!!r.opaque,status:p(t),triggers:g(t,e),clientOps:b(t,e),computes:l(t,e),fields:v(t,e),show:A(t,e),text:y(t,e),filter:h(t)}}function s(t){if(!t)return{opaque:!0};try{let e=t.split("--")[0],a=d(e),r=JSON.parse(a),i=r&&r._rails&&r._rails.data?r._rails.data:r;if(!i||typeof i!=="object")return{opaque:!0};return i}catch{return{opaque:!0}}}function d(t){return(globalThis.atob||((a)=>Buffer.from(a,"base64").toString("binary")))(t)}function p(t){return{error:t.getAttribute("data-reactive-error"),busy:t.getAttribute("aria-busy")==="true"||t.hasAttribute("data-reactive-busy"),dirty:t.hasAttribute("data-reactive-dirty")}}var f='[data-controller~="reactive"]';function m(t,e){return t.closest(f)===e}function n(t,e,a){return Array.from(t.querySelectorAll(e)).filter((r)=>m(r,t))}function g(t,e){return n(t,"[data-reactive-action-param]",e).map((a)=>({action:a.getAttribute("data-reactive-action-param"),event:o(a.getAttribute("data-action")),params:a.getAttribute("data-reactive-params-param"),debounce:a.getAttribute("data-reactive-debounce-param"),throttle:a.getAttribute("data-reactive-throttle-param"),confirm:a.getAttribute("data-reactive-confirm-param"),optimistic:a.getAttribute("data-reactive-optimistic-param"),loading:a.getAttribute("data-reactive-loading-param")}))}function o(t){if(!t)return null;let e=t.indexOf("->");return e===-1?null:t.slice(0,e)}function b(t,e){return n(t,"[data-reactive-ops-param]",e).map((a)=>{let r=a.getAttribute("data-reactive-ops-param"),i=r;try{i=JSON.parse(r)}catch{}return{event:o(a.getAttribute("data-action")),ops:i}})}function l(t,e){return n(t,"[data-reactive-compute-reducer-param]",e).map((a)=>a.getAttribute("data-reactive-compute-reducer-param"))}function v(t,e){return n(t,"input[name], select[name], textarea[name]",e).map((a)=>a.getAttribute("name"))}function A(t,e){return n(t,"[data-reactive-show-field]",e).map((a)=>({field:a.getAttribute("data-reactive-show-field"),equals:a.getAttribute("data-reactive-show-equals"),not:a.getAttribute("data-reactive-show-not"),in:a.getAttribute("data-reactive-show-in")}))}function y(t,e){return n(t,"[data-reactive-text]",e).map((a)=>a.getAttribute("data-reactive-text"))}function h(t){let e=t.getAttribute("data-reactive-filter-input");if(!e)return null;return{input:e,option:t.getAttribute("data-reactive-filter-option"),group:t.getAttribute("data-reactive-filter-group"),empty:t.getAttribute("data-reactive-filter-empty")}}export{w as report,u as scan};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=C4F6AC69EE45C88764756E2164756E21
|
|
4
4
|
//# sourceMappingURL=inspect.min.js.map
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
"// The on-demand client inspector (issue #168) — the browser half of the\n// debugging toolkit, the client twin of `phlex_reactive:actions` / the MCP tools.\n//\n// It is a STANDALONE module (the confirm.js / compute.js precedent): it does NOT\n// touch the hot-path reactive_controller.js and costs NOTHING until you import\n// it. Loaded on demand from the console (the engine pins it), it scans the live\n// DOM and maps every reactive root + bound trigger back to the server\n// Component#action names — so you can answer \"what's reactive on this page, what\n// does each control POST, and which component owns it?\" without reading source.\n//\n// (await import(\"phlex/reactive/inspect\")).report() // console.table\n// const roots = (await import(\"phlex/reactive/inspect\")).scan() // data\n//\n// The server↔client mapping is BY NAME: scan()'s `component` + each trigger's\n// `action` are exactly the Component#action identifiers `phlex_reactive:actions`\n// and the MCP tools list. The docs page shows the two side by side.\n//\n// It reads the SAME DOM contract reactive_controller.js writes (roots\n// [data-controller~=\"reactive\"] with id + data-reactive-token-value; triggers\n// [data-reactive-action-param] with data-action/params/modifiers; client-only\n// [data-reactive-ops-param]; computes [data-reactive-compute-reducer-param]; the\n// show/filter/text binding families). It never mutates anything — pure read.\n\n// Scan `root` (default the whole document) and return one entry per reactive\n// root, deepest-scoped: each trigger is attributed to its NEAREST reactive root,\n// so a nested root doesn't double-count its parent's triggers.\nexport function scan(root = document) {\n const roots = Array.from(root.querySelectorAll('[data-controller~=\"reactive\"]'))\n return roots.map((el) => inspectRoot(el, roots))\n}\n\n// console.table the inventory (roots + a flattened trigger table), and return\n// the same data scan() does so `report()` is also usable programmatically. Safe\n// on an empty document (logs an empty table, throws nothing).\nexport function report(root = document) {\n const roots = scan(root)\n const console = globalThis.console\n\n // Guard each console method independently — a host page (or a test harness)\n // may ship a partial console, and report() must never throw over logging.\n if (console && typeof console.log === \"function\") {\n console.log(`[phlex-reactive] ${roots.length} reactive root(s)`)\n }\n if (console && typeof console.table === \"function\") {\n console.table(\n roots.map((r) => ({\n id: r.id,\n component: r.component ?? (r.opaque ? \"(opaque token)\" : null),\n actions: r.triggers.map((t) => t.action).join(\", \"),\n fields: r.fields.join(\", \"),\n computes: r.computes.join(\", \"),\n })),\n )\n }\n return roots\n}\n\n// --- one root --------------------------------------------------------------\n\nfunction inspectRoot(el, allRoots) {\n const token = el.getAttribute(\"data-reactive-token-value\")\n const payload = decodeToken(token)\n\n return {\n id: el.id || null,\n component: payload.opaque ? null : (payload.c ?? null),\n gid: payload.gid ?? null,\n stateKeys: payload.s ? Object.keys(payload.s) : [],\n tokenVersion: payload.v ?? null,\n opaque: !!payload.opaque,\n status: readStatus(el),\n triggers: ownedTriggers(el, allRoots),\n clientOps: ownedClientOps(el, allRoots),\n computes: ownedComputes(el, allRoots),\n fields: ownedFields(el, allRoots),\n show: ownedShow(el, allRoots),\n text: ownedText(el, allRoots),\n filter: readFilter(el),\n }\n}\n\n// The signed identity is base64(JSON)--signature under the Rails 7.1+ JSON\n// message serializer. Decode the segment BEFORE the \"--\", JSON-parse it, and\n// unwrap the MessageVerifier envelope ({_rails:{data:…}}) to the {c,gid,s,v}\n// payload. Degrade to { opaque: true } on anything that isn't JSON-decodable —\n// a Marshal-serialized payload (older apps) simply isn't readable client-side,\n// and that's fine: the inspector shows \"(opaque token)\" rather than throwing.\nfunction decodeToken(token) {\n if (!token) return { opaque: true }\n\n try {\n const segment = token.split(\"--\")[0]\n const json = atobUtf8(segment)\n const parsed = JSON.parse(json)\n const data = parsed && parsed._rails && parsed._rails.data ? parsed._rails.data : parsed\n if (!data || typeof data !== \"object\") return { opaque: true }\n return data\n } catch {\n return { opaque: true }\n }\n}\n\n// base64 → string. Uses the platform atob; a malformed/urlsafe segment throws,\n// which decodeToken catches and reports as opaque.\nfunction atobUtf8(b64) {\n const decode = globalThis.atob || ((s) => Buffer.from(s, \"base64\").toString(\"binary\"))\n return decode(b64)\n}\n\n// The at-a-glance runtime state the controller stamps on the root. Names/flags\n// only — never a value that could be sensitive.\nfunction readStatus(el) {\n return {\n error: el.getAttribute(\"data-reactive-error\"),\n busy: el.getAttribute(\"aria-busy\") === \"true\" || el.hasAttribute(\"data-reactive-busy\"),\n dirty: el.hasAttribute(\"data-reactive-dirty\"),\n }\n}\n\n// --- ownership scoping -----------------------------------------------------\n//\n// An element belongs to a root when its NEAREST reactive-root ancestor is that\n// root (matching the controller's own ownership walk). closest() finds the\n// nearest ancestor matching the selector; the element itself can be the root\n// (for a root that also carries a binding), so we start the walk from the\n// element and accept `el` as its own owner.\n\nconst ROOT_SELECTOR = '[data-controller~=\"reactive\"]'\n\nfunction ownedBy(node, root) {\n return node.closest(ROOT_SELECTOR) === root\n}\n\n// Query `root` for `selector`, keeping only elements whose nearest reactive\n// root IS `root` — so a nested root's matches are excluded here and attributed\n// to that inner root instead.\nfunction scopedQuery(root, selector, allRoots) {\n return Array.from(root.querySelectorAll(selector)).filter((node) => ownedBy(node, root))\n}\n\n// --- the binding families --------------------------------------------------\n\nfunction ownedTriggers(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-action-param]\", allRoots).map((el) => ({\n action: el.getAttribute(\"data-reactive-action-param\"),\n event: eventOf(el.getAttribute(\"data-action\")),\n params: el.getAttribute(\"data-reactive-params-param\"),\n debounce: el.getAttribute(\"data-reactive-debounce-param\"),\n throttle: el.getAttribute(\"data-reactive-throttle-param\"),\n confirm: el.getAttribute(\"data-reactive-confirm-param\"),\n optimistic: el.getAttribute(\"data-reactive-optimistic-param\"),\n loading: el.getAttribute(\"data-reactive-loading-param\"),\n }))\n}\n\n// The Stimulus data-action descriptor is \"event->controller#method\"; the event\n// is the part before \"->\" (a bare \"controller#method\" has an implicit default\n// event, which we report as null).\nfunction eventOf(dataAction) {\n if (!dataAction) return null\n const arrow = dataAction.indexOf(\"->\")\n return arrow === -1 ? null : dataAction.slice(0, arrow)\n}\n\n// Client-only op triggers ([data-reactive-ops-param]) — a menu toggle, a DOM op\n// chain that never round-trips. Report the parsed ops (names) when decodable.\nfunction ownedClientOps(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-ops-param]\", allRoots).map((el) => {\n const raw = el.getAttribute(\"data-reactive-ops-param\")\n let ops = raw\n try {\n ops = JSON.parse(raw)\n } catch {\n // leave the raw string — a malformed ops attr shouldn't break the scan\n }\n return { event: eventOf(el.getAttribute(\"data-action\")), ops }\n })\n}\n\n// Compute reducers bound on this root ([data-reactive-compute-reducer-param]) —\n// the client-side data-binding twins of reactive_compute. Report reducer names.\nfunction ownedComputes(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-compute-reducer-param]\", allRoots).map((el) =>\n el.getAttribute(\"data-reactive-compute-reducer-param\"),\n )\n}\n\n// The named form controls the dispatch would collect and send — the same\n// selector the controller's field walk uses. Names only, never values.\nfunction ownedFields(root, allRoots) {\n return scopedQuery(root, \"input[name], select[name], textarea[name]\", allRoots).map((el) =>\n el.getAttribute(\"name\"),\n )\n}\n\n// reactive_show bindings: which owned field controls each element's visibility.\nfunction ownedShow(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-show-field]\", allRoots).map((el) => ({\n field: el.getAttribute(\"data-reactive-show-field\"),\n equals: el.getAttribute(\"data-reactive-show-equals\"),\n not: el.getAttribute(\"data-reactive-show-not\"),\n in: el.getAttribute(\"data-reactive-show-in\"),\n }))\n}\n\n// reactive_text spans (data-reactive-text=<name>) the client writes via\n// textContent — report the binding names.\nfunction ownedText(root, allRoots) {\n return scopedQuery(root, \"[data-reactive-text]\", allRoots).map((el) =>\n el.getAttribute(\"data-reactive-text\"),\n )\n}\n\n// reactive_filter lives on the ROOT itself (the input/option/group/empty\n// selectors it narrows client-side). Null when the root declares no filter.\nfunction readFilter(el) {\n const input = el.getAttribute(\"data-reactive-filter-input\")\n if (!input) return null\n return {\n input,\n option: el.getAttribute(\"data-reactive-filter-option\"),\n group: el.getAttribute(\"data-reactive-filter-group\"),\n empty: el.getAttribute(\"data-reactive-filter-empty\"),\n }\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
|
-
"debugId": "
|
|
8
|
+
"debugId": "C4F6AC69EE45C88764756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -1065,8 +1065,12 @@ function runTransition(el, transition, flip) {
|
|
|
1065
1065
|
// persist_clear client ops (which receive only the element) share it. Every
|
|
1066
1066
|
// storage access is try/catch'd: a private window, a quota error or blocked
|
|
1067
1067
|
// storage degrades to "no draft", never a thrown bootstrap. Nothing here
|
|
1068
|
-
// leaves the browser: no token, no POST,
|
|
1069
|
-
//
|
|
1068
|
+
// leaves the browser: no token, no POST, and phlex-reactive never writes
|
|
1069
|
+
// markup into the DOM: native controls are replayed via
|
|
1070
|
+
// .value/.checked/.selected, a bare [contenteditable] via textContent, and a
|
|
1071
|
+
// rich editor (lexxy-editor, trix-editor — issue #241) through its OWN
|
|
1072
|
+
// `value` setter, the same sanitizing import path a paste takes. Never
|
|
1073
|
+
// innerHTML.
|
|
1070
1074
|
// ---------------------------------------------------------------------------
|
|
1071
1075
|
const PERSIST_VERSION = 1
|
|
1072
1076
|
const PERSIST_PREFIX = "phlex-reactive:persist:"
|
|
@@ -1074,10 +1078,29 @@ const PERSIST_PREFIX = "phlex-reactive:persist:"
|
|
|
1074
1078
|
// into (hidden, file), secrets (password), and non-value controls.
|
|
1075
1079
|
const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
|
|
1076
1080
|
const PERSIST_STATE_ATTR = "data-reactive-persist-state"
|
|
1081
|
+
// The editor query #collectFields reads (minus the [name] guard — an editor's
|
|
1082
|
+
// name may live on its IDL `name` getter: Trix's `input=`-paired hidden input).
|
|
1083
|
+
const PERSIST_EDITOR_SELECTOR =
|
|
1084
|
+
":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
|
|
1085
|
+
const PERSIST_EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
|
|
1086
|
+
// An editor's own chrome: Lexxy renders its toolbar (a `lexxy-code-language`
|
|
1087
|
+
// select, the link dialog's `href` input) INSIDE <lexxy-editor>, Trix as a
|
|
1088
|
+
// sibling <trix-toolbar>. Those are named native controls that are not the
|
|
1089
|
+
// user's fields — never drafted, never restored into.
|
|
1090
|
+
const PERSIST_EDITOR_CHROME = "lexxy-editor, trix-editor, trix-toolbar"
|
|
1091
|
+
// The editors' own bubbling change events — the keystroke signal for the
|
|
1092
|
+
// draft write, since neither lets its contenteditable's native `input` bubble.
|
|
1093
|
+
const PERSIST_EDITOR_CHANGE_EVENTS = ["lexxy:change", "trix-change"]
|
|
1094
|
+
// "Is this editor empty" when it exposes no predicate of its own (Lexxy's
|
|
1095
|
+
// `isEmpty`, Trix's `editor.getDocument().isEmpty()`): Lexxy's own empty
|
|
1096
|
+
// list plus the Trix / contenteditable empties. Exact strings, no HTML
|
|
1097
|
+
// parsing — an attachment-only body stays non-blank.
|
|
1098
|
+
const PERSIST_EMPTY_HTML = new Set(["", "<p></p>", "<p><br></p>", "<div><br></div>"])
|
|
1077
1099
|
// Once-per-root guards: a malformed payload warns once; the storage-failure
|
|
1078
|
-
// dev
|
|
1100
|
+
// and editor-restore dev notes (debug mode only) print once each.
|
|
1079
1101
|
const persistPayloadWarned = new WeakSet()
|
|
1080
1102
|
const persistFailureNoted = new WeakSet()
|
|
1103
|
+
const persistEditorNoted = new WeakSet()
|
|
1081
1104
|
|
|
1082
1105
|
// The root's parsed payload, or null (undeclared / malformed → warned once).
|
|
1083
1106
|
// A control-level "off" (reactive_persist_skip) is never a root payload.
|
|
@@ -1175,32 +1198,79 @@ function persistRemove(root, payload) {
|
|
|
1175
1198
|
|
|
1176
1199
|
// The persistable controls this root OWNS (#15: a nested reactive root's
|
|
1177
1200
|
// controls are its own), minus the excluded types, the reactive_persist_skip
|
|
1178
|
-
// marker, and — when `fields` narrows the set — any undeclared name.
|
|
1201
|
+
// marker, and — when `fields` narrows the set — any undeclared name. Each
|
|
1202
|
+
// entry is { el, name, kind } with kind "native" (input/select/textarea),
|
|
1203
|
+
// "editor" (lexxy-editor / trix-editor) or "contenteditable" (a bare named
|
|
1204
|
+
// editable element) — issue #241.
|
|
1179
1205
|
function persistControls(root, payload) {
|
|
1180
1206
|
const allow = Array.isArray(payload.fields) ? new Set(payload.fields) : null
|
|
1181
1207
|
const out = []
|
|
1208
|
+
const owned = (el) =>
|
|
1209
|
+
el.closest('[data-controller~="reactive"]') === root && el.getAttribute("data-reactive-persist") !== "off"
|
|
1182
1210
|
for (const el of root.querySelectorAll("input[name], select[name], textarea[name]")) {
|
|
1183
|
-
if (el.
|
|
1184
|
-
if (PERSIST_EXCLUDED_TYPES.has(el.type)) continue
|
|
1185
|
-
if (el.getAttribute("data-reactive-persist") === "off") continue
|
|
1211
|
+
if (!owned(el) || PERSIST_EXCLUDED_TYPES.has(el.type) || el.closest(PERSIST_EDITOR_CHROME)) continue
|
|
1186
1212
|
if (allow && !allow.has(el.name)) continue
|
|
1187
|
-
out.push(el)
|
|
1213
|
+
out.push({ el, name: el.name, kind: "native" })
|
|
1214
|
+
}
|
|
1215
|
+
for (const el of root.querySelectorAll(PERSIST_EDITOR_SELECTOR)) {
|
|
1216
|
+
if (!owned(el)) continue
|
|
1217
|
+
const name = persistEditorName(el)
|
|
1218
|
+
if (!name || (allow && !allow.has(name))) continue
|
|
1219
|
+
out.push({ el, name, kind: PERSIST_EDITOR_TAGS.has(el.localName) ? "editor" : "contenteditable" })
|
|
1188
1220
|
}
|
|
1189
1221
|
return out
|
|
1190
1222
|
}
|
|
1191
1223
|
|
|
1224
|
+
// The attribute first (Lexxy, a bare contenteditable — which has no `name`
|
|
1225
|
+
// IDL property at all), then the IDL getter (Trix resolves it through its
|
|
1226
|
+
// `input=`-paired hidden input, which is itself excluded as type=hidden).
|
|
1227
|
+
function persistEditorName(el) {
|
|
1228
|
+
return el.getAttribute("name") || (typeof el.name === "string" && el.name) || null
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// An editor is READY once its custom element has upgraded and connected: only
|
|
1232
|
+
// then does it expose the string `value` accessor (Lexxy's setter throws
|
|
1233
|
+
// before connectedCallback created its editor; Trix's discards the value).
|
|
1234
|
+
function persistEditorReady(el) {
|
|
1235
|
+
return typeof el.value === "string"
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
|
|
1239
|
+
// `editor.getDocument().isEmpty()`), else the exact-string fallback. An
|
|
1240
|
+
// attachment-only server body is therefore NON-blank and never overwritten.
|
|
1241
|
+
function persistEditorBlank(el) {
|
|
1242
|
+
if (typeof el.isEmpty === "boolean") return el.isEmpty
|
|
1243
|
+
const doc = el.editor?.getDocument?.()
|
|
1244
|
+
if (typeof doc?.isEmpty === "function") return doc.isEmpty()
|
|
1245
|
+
return PERSIST_EMPTY_HTML.has(el.value.trim())
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// The editor-restore dev lens: ONLY under data-reactive-debug, once per root.
|
|
1249
|
+
function persistNoteEditorFailure(root, name, error) {
|
|
1250
|
+
if (root?.getAttribute?.("data-reactive-debug") !== "true" || persistEditorNoted.has(root)) return
|
|
1251
|
+
persistEditorNoted.add(root)
|
|
1252
|
+
console.info(
|
|
1253
|
+
`[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(name)} — ${error?.message ?? error}`,
|
|
1254
|
+
)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1192
1257
|
function persistSelectMultiple(el) {
|
|
1193
1258
|
return el.tagName === "SELECT" && el.multiple
|
|
1194
1259
|
}
|
|
1195
1260
|
|
|
1196
1261
|
// Snapshot the owned controls: radio → the checked value (null when the group
|
|
1197
1262
|
// has none, so a restore leaves it alone), checkbox → checked, multi-select →
|
|
1198
|
-
// the selected values,
|
|
1263
|
+
// the selected values, a rich editor → its serialized `value` (omitted while
|
|
1264
|
+
// the element hasn't upgraded — never a phantom ""), a bare contenteditable →
|
|
1265
|
+
// its textContent, else .value. Mirrors #collectFields' reads.
|
|
1199
1266
|
function persistSnapshot(root, payload) {
|
|
1200
1267
|
const fields = {}
|
|
1201
|
-
for (const el of persistControls(root, payload)) {
|
|
1202
|
-
|
|
1203
|
-
|
|
1268
|
+
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1269
|
+
if (kind === "editor") {
|
|
1270
|
+
if (persistEditorReady(el)) fields[name] = el.value
|
|
1271
|
+
} else if (kind === "contenteditable") {
|
|
1272
|
+
fields[name] = el.textContent ?? ""
|
|
1273
|
+
} else if (el.type === "radio") {
|
|
1204
1274
|
if (el.checked) fields[name] = el.value
|
|
1205
1275
|
else if (!Object.hasOwn(fields, name)) fields[name] = null
|
|
1206
1276
|
} else if (el.type === "checkbox") {
|
|
@@ -1217,16 +1287,22 @@ function persistSnapshot(root, payload) {
|
|
|
1217
1287
|
// Replay the draft into the owned controls. Default (restore: blank): a
|
|
1218
1288
|
// control the server rendered NON-BLANK keeps its value — a 422 re-render's
|
|
1219
1289
|
// submitted values beat an older draft. restore: "always" lets the draft win.
|
|
1220
|
-
// Values land via .value/.checked/.selected
|
|
1290
|
+
// Values land via .value/.checked/.selected, textContent, or the editor's own
|
|
1291
|
+
// `value` setter — never HTML written by us.
|
|
1221
1292
|
function persistApply(root, payload, fields) {
|
|
1222
1293
|
const always = payload.restore === "always"
|
|
1223
1294
|
const controls = persistControls(root, payload)
|
|
1224
|
-
for (const el of controls) {
|
|
1225
|
-
if (!Object.hasOwn(fields,
|
|
1226
|
-
const value = fields[
|
|
1295
|
+
for (const { el, name, kind } of controls) {
|
|
1296
|
+
if (!Object.hasOwn(fields, name)) continue
|
|
1297
|
+
const value = fields[name]
|
|
1227
1298
|
if (value === null || value === undefined) continue
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1299
|
+
if (kind === "editor") {
|
|
1300
|
+
persistApplyEditor(root, el, name, value, always)
|
|
1301
|
+
} else if (kind === "contenteditable") {
|
|
1302
|
+
if (!always && (el.textContent ?? "").trim() !== "") continue
|
|
1303
|
+
el.textContent = String(value)
|
|
1304
|
+
} else if (el.type === "radio") {
|
|
1305
|
+
if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
|
|
1230
1306
|
el.checked = el.value === String(value)
|
|
1231
1307
|
} else if (el.type === "checkbox") {
|
|
1232
1308
|
if (!always && el.checked) continue
|
|
@@ -1240,6 +1316,46 @@ function persistApply(root, payload, fields) {
|
|
|
1240
1316
|
el.value = String(value)
|
|
1241
1317
|
}
|
|
1242
1318
|
}
|
|
1319
|
+
persistDeferEditors(root, payload, fields)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// A ready editor takes the value through its own setter; the setter is the
|
|
1323
|
+
// editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
|
|
1324
|
+
// sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
|
|
1325
|
+
function persistApplyEditor(root, el, name, value, always) {
|
|
1326
|
+
if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
|
|
1327
|
+
if (!always && !persistEditorBlank(el)) return
|
|
1328
|
+
try {
|
|
1329
|
+
el.value = String(value)
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
persistNoteEditorFailure(root, name, error)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// An editor whose custom element is not defined yet (Trix defines its elements
|
|
1336
|
+
// in a setTimeout after load; a lazily imported Lexxy) cannot take a value now.
|
|
1337
|
+
// Re-apply per TAG once it is defined — re-querying the controls (the upgrade
|
|
1338
|
+
// may replace the node) and re-checking restore: blank at that moment — unless
|
|
1339
|
+
// the root has left the document meanwhile.
|
|
1340
|
+
function persistDeferEditors(root, payload, fields) {
|
|
1341
|
+
const registry = globalThis.customElements
|
|
1342
|
+
if (typeof registry?.whenDefined !== "function") return
|
|
1343
|
+
const pending = new Set()
|
|
1344
|
+
for (const el of root.querySelectorAll("lexxy-editor, trix-editor")) {
|
|
1345
|
+
if (!persistEditorReady(el) && !registry.get?.(el.localName)) pending.add(el.localName)
|
|
1346
|
+
}
|
|
1347
|
+
const always = payload.restore === "always"
|
|
1348
|
+
for (const tag of pending) {
|
|
1349
|
+
registry.whenDefined(tag).then(() => {
|
|
1350
|
+
if (!root.isConnected) return
|
|
1351
|
+
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1352
|
+
if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
|
|
1353
|
+
const value = fields[name]
|
|
1354
|
+
if (value === null || value === undefined) continue
|
|
1355
|
+
persistApplyEditor(root, el, name, value, always)
|
|
1356
|
+
}
|
|
1357
|
+
})
|
|
1358
|
+
}
|
|
1243
1359
|
}
|
|
1244
1360
|
|
|
1245
1361
|
// The persist_state op body: merge a FLAT bag into the root's draft (re-
|
|
@@ -4244,6 +4360,10 @@ export default class extends Controller {
|
|
|
4244
4360
|
this.#boundPersistSubmitEnd = (event) => this.#persistSubmitEnd(event)
|
|
4245
4361
|
this.element.addEventListener?.("input", this.#boundPersistInput)
|
|
4246
4362
|
this.element.addEventListener?.("change", this.#boundPersistChange)
|
|
4363
|
+
// Rich editors (#241): Lexical and Trix swallow the native `input` of
|
|
4364
|
+
// their contenteditable, so their own bubbling change events are the
|
|
4365
|
+
// keystroke signal — same trailing-edge debounce as `input`.
|
|
4366
|
+
for (const event of PERSIST_EDITOR_CHANGE_EVENTS) this.element.addEventListener?.(event, this.#boundPersistInput)
|
|
4247
4367
|
document.addEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
|
|
4248
4368
|
}
|
|
4249
4369
|
|
|
@@ -4296,6 +4416,7 @@ export default class extends Controller {
|
|
|
4296
4416
|
if (this.#persistTimer !== null) this.#persistWriteNow()
|
|
4297
4417
|
this.element.removeEventListener?.("input", this.#boundPersistInput)
|
|
4298
4418
|
this.element.removeEventListener?.("change", this.#boundPersistChange)
|
|
4419
|
+
for (const event of PERSIST_EDITOR_CHANGE_EVENTS) this.element.removeEventListener?.(event, this.#boundPersistInput)
|
|
4299
4420
|
document.removeEventListener?.("turbo:submit-end", this.#boundPersistSubmitEnd)
|
|
4300
4421
|
this.#boundPersistInput = this.#boundPersistChange = this.#boundPersistSubmitEnd = undefined
|
|
4301
4422
|
this.#persistConfig = null
|