phlex-reactive 0.4.8 → 0.9.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 +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -73,9 +73,201 @@ export function registerReactiveToken() {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Custom turbo-stream action: SERVER-PUSHED client DOM ops (issue #97). The
|
|
77
|
+
// server-side sibling of on_client's runOps — a reply (reply.<verb>.js(ops)) or
|
|
78
|
+
// a broadcast (Streamable.broadcast_js_to) emits
|
|
79
|
+
//
|
|
80
|
+
// <turbo-stream action="reactive:js" target="<optional root id>"
|
|
81
|
+
// data-reactive-ops="[[op, args], ...]"></turbo-stream>
|
|
82
|
+
//
|
|
83
|
+
// and Turbo invokes this handler with `this` bound to that <turbo-stream>
|
|
84
|
+
// element. It runs the ops through the SAME frozen CLIENT_OPS whitelist as
|
|
85
|
+
// runOps (client-side default-deny — an unknown op warns + is skipped), so a
|
|
86
|
+
// forged/stale ops attr can never break the page or execute anything off the
|
|
87
|
+
// vocabulary. NO token, NO fetch — a pure local DOM mutation.
|
|
88
|
+
//
|
|
89
|
+
// `target` (optional, an element id) scopes op resolution to that root: "@root"
|
|
90
|
+
// resolves to the target element itself and a selector resolves WITHIN it.
|
|
91
|
+
// Without a target, ops resolve document-wide (a broadcast op like
|
|
92
|
+
// add_class("#bell", ...) that isn't anchored to one component). The op stream
|
|
93
|
+
// is emitted AFTER all render streams in the reply (the endpoint appends it
|
|
94
|
+
// last), so focus("[name=next]") sees the freshly morphed DOM — Turbo applies
|
|
95
|
+
// streams in document order.
|
|
96
|
+
export function registerReactiveJs() {
|
|
97
|
+
const actions = window.Turbo?.StreamActions
|
|
98
|
+
if (!actions || actions["reactive:js"]) return
|
|
99
|
+
actions["reactive:js"] = function () {
|
|
100
|
+
const list = parseOps(this.getAttribute("data-reactive-ops"))
|
|
101
|
+
if (!list.length) return
|
|
102
|
+
const targetId = this.getAttribute("target")
|
|
103
|
+
// With a target: scope to that element (missing → no-op). Without: document.
|
|
104
|
+
const root = targetId ? document.getElementById(targetId) : null
|
|
105
|
+
if (targetId && !root) return
|
|
106
|
+
applyOps(list, (args) => streamOpTargets(args, root))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Document-level self-dismissing flashes (issue #100). A flash rendered with
|
|
111
|
+
// dismiss_after: carries data-reactive-dismiss-after="<ms>"; after the timeout
|
|
112
|
+
// it removes itself. This is deliberately NOT a Stimulus controller — the flash
|
|
113
|
+
// container is a plain host-app div (Response#flash appends into it) with no
|
|
114
|
+
// controller attached, so nothing would honor the attr. A document-level scan
|
|
115
|
+
// on turbo:before-stream-render (which fires for EVERY <turbo-stream> render —
|
|
116
|
+
// a reply AND a broadcast) schedules removal for any newly-arrived dismissing
|
|
117
|
+
// flash. Each is marked data-reactive-dismiss-scheduled so re-scans (a later
|
|
118
|
+
// stream render) never double-schedule the same node. Registered once; the
|
|
119
|
+
// guard flag makes a second call a no-op (bun imports the module once per run).
|
|
120
|
+
let dismissRegistered = false
|
|
121
|
+
export function registerReactiveDismiss() {
|
|
122
|
+
if (dismissRegistered) return
|
|
123
|
+
if (typeof document === "undefined" || !document.addEventListener) return
|
|
124
|
+
dismissRegistered = true
|
|
125
|
+
// turbo:before-stream-render fires BEFORE the stream is applied — and Turbo
|
|
126
|
+
// then does `await nextRepaint(); await event.detail.render(this)`, so a bare
|
|
127
|
+
// setTimeout(0) can run BEFORE the node is inserted (observed under Falcon).
|
|
128
|
+
// WRAP event.detail.render instead: run Turbo's own render, then scan once it
|
|
129
|
+
// has resolved — timing-independent and correct on every server. The event
|
|
130
|
+
// fires for EVERY <turbo-stream> (a reply AND a broadcast), so both delivery
|
|
131
|
+
// paths self-clean. detail.render may be absent on exotic streams — guard it.
|
|
132
|
+
document.addEventListener("turbo:before-stream-render", wrapStreamRenderForDismiss)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Chain the dismissing-flash scan after Turbo's own stream render resolves, so
|
|
136
|
+
// the scan sees the freshly-inserted node. Idempotent per event (marks
|
|
137
|
+
// detail.render as already-wrapped) and defensive if detail/render is missing.
|
|
138
|
+
function wrapStreamRenderForDismiss(event) {
|
|
139
|
+
const detail = event.detail
|
|
140
|
+
const original = detail?.render
|
|
141
|
+
if (typeof original !== "function" || original.__reactiveDismissWrapped) {
|
|
142
|
+
// No render to wrap (or already wrapped) — fall back to a post-repaint scan.
|
|
143
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(scheduleReactiveDismissals)
|
|
144
|
+
else setTimeout(scheduleReactiveDismissals, 0)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const wrapped = async (streamElement) => {
|
|
148
|
+
await original(streamElement)
|
|
149
|
+
scheduleReactiveDismissals()
|
|
150
|
+
}
|
|
151
|
+
wrapped.__reactiveDismissWrapped = true
|
|
152
|
+
detail.render = wrapped
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Scan for un-scheduled dismissing flashes and schedule each one's removal.
|
|
156
|
+
// Kept a module function so the scan logic is testable and re-run on every
|
|
157
|
+
// stream render.
|
|
158
|
+
function scheduleReactiveDismissals() {
|
|
159
|
+
const flashes = document.querySelectorAll("[data-reactive-dismiss-after]")
|
|
160
|
+
for (const el of flashes) {
|
|
161
|
+
if (el.hasAttribute("data-reactive-dismiss-scheduled")) continue
|
|
162
|
+
const ms = Number(el.getAttribute("data-reactive-dismiss-after"))
|
|
163
|
+
if (!Number.isFinite(ms) || ms <= 0) continue
|
|
164
|
+
el.setAttribute("data-reactive-dismiss-scheduled", "")
|
|
165
|
+
setTimeout(() => el.remove(), ms)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Test seam: reset the one-time registration guard so a fresh document stub in
|
|
170
|
+
// the next test registers its own listener (bun runs all specs in one process).
|
|
171
|
+
export function __resetReactiveDismissForTest() {
|
|
172
|
+
dismissRegistered = false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Offline CSS hook (issue #101). Mirror data-reactive-offline on
|
|
176
|
+
// document.documentElement from navigator.onLine, kept in sync by the window
|
|
177
|
+
// online/offline events — so an app can dim a save button or show a banner with
|
|
178
|
+
// PURE CSS and zero JS ([data-reactive-offline] .save { pointer-events: none }).
|
|
179
|
+
// Guarded on window (needed for addEventListener AND navigator) so importing the
|
|
180
|
+
// module in a non-browser (bun test) context is a no-op, and registered once
|
|
181
|
+
// (the online/offline listeners are NOT {once}, so a second registerReactiveActions
|
|
182
|
+
// call must not stack duplicates) — mirroring the dismiss guard + reset seam.
|
|
183
|
+
let offlineRegistered = false
|
|
184
|
+
export function registerReactiveOffline() {
|
|
185
|
+
if (offlineRegistered) return
|
|
186
|
+
if (typeof window === "undefined" || typeof document === "undefined") return
|
|
187
|
+
if (typeof window.addEventListener !== "function") return
|
|
188
|
+
offlineRegistered = true
|
|
189
|
+
// toggleAttribute(name, force) writes data-reactive-offline="" (a bare boolean
|
|
190
|
+
// attr the [data-reactive-offline] selector matches) or removes it — never the
|
|
191
|
+
// "true" string. navigator.onLine === false is the reliable direction (a false
|
|
192
|
+
// "online" is spec-permitted but rare, and this is only a presentational hook —
|
|
193
|
+
// the authoritative offline signal is the #perform gate, not this attribute).
|
|
194
|
+
// Fully defensive: a missing documentElement/toggleAttribute/navigator degrades
|
|
195
|
+
// to a no-op — a presentational hook must NEVER throw during bootstrap.
|
|
196
|
+
const sync = () => {
|
|
197
|
+
const root = document.documentElement
|
|
198
|
+
if (typeof root?.toggleAttribute !== "function") return
|
|
199
|
+
root.toggleAttribute("data-reactive-offline", globalThis.navigator?.onLine === false)
|
|
200
|
+
}
|
|
201
|
+
sync() // seed synchronously so first paint is correct
|
|
202
|
+
window.addEventListener("online", sync)
|
|
203
|
+
window.addEventListener("offline", sync)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function __resetReactiveOfflineForTest() {
|
|
207
|
+
offlineRegistered = false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Latency simulator dev aid (issue #102). On localhost the click→morph round
|
|
211
|
+
// trip is ~5ms, so the pending/loading/optimistic affordances (aria-busy,
|
|
212
|
+
// disable_with, busy_on, optimistic hints) flash by too fast to see while
|
|
213
|
+
// developing or demoing them — the reason LiveView ships enableLatencySim(ms).
|
|
214
|
+
//
|
|
215
|
+
// enableLatencySim(ms) persists the delay to sessionStorage (session-scoped, so
|
|
216
|
+
// it clears when the tab closes — never a config you forget you left on);
|
|
217
|
+
// #perform reads it right before the fetch and awaits setTimeout(ms), stretching
|
|
218
|
+
// the already-set busy window to something visible. disableLatencySim() clears
|
|
219
|
+
// it. NAMED exports (the setConfirmResolver precedent) — but importmap module
|
|
220
|
+
// exports are unreachable from the browser console, so registerReactiveActions
|
|
221
|
+
// ALSO attaches these to window.PhlexReactive, and ONLY when the app opts in with
|
|
222
|
+
// <meta name="phlex-reactive-env" content="development"> (see #attachLatencyHandle).
|
|
223
|
+
export const LATENCY_KEY = "phlex-reactive:latency"
|
|
224
|
+
|
|
225
|
+
// One-time "sim active" banner guard (module-level, mirroring offlineRegistered):
|
|
226
|
+
// #maybeSimulateLatency warns ONCE while the sim is on, not once per request.
|
|
227
|
+
let latencyBannerShown = false
|
|
228
|
+
|
|
229
|
+
export function enableLatencySim(ms) {
|
|
230
|
+
if (typeof sessionStorage === "undefined") return
|
|
231
|
+
sessionStorage.setItem(LATENCY_KEY, String(ms))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function disableLatencySim() {
|
|
235
|
+
if (typeof sessionStorage === "undefined") return
|
|
236
|
+
sessionStorage.removeItem(LATENCY_KEY)
|
|
237
|
+
// Re-arm the one-time "sim active" banner: turning the sim OFF is the lifecycle
|
|
238
|
+
// boundary, so a later enableLatencySim() in the same session re-announces that
|
|
239
|
+
// the sim is on (otherwise the guard would stay set across an off→on cycle and
|
|
240
|
+
// swallow the banner). Matches the __resetReactiveLatencyForTest seam.
|
|
241
|
+
latencyBannerShown = false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The dev gate. importmap module exports aren't reachable from the DevTools
|
|
245
|
+
// console, so we expose the two functions on a window handle — but ONLY when the
|
|
246
|
+
// app authored <meta name="phlex-reactive-env" content="development">. There is
|
|
247
|
+
// NO engine-emitted meta (the engine can't inject into the host layout); the
|
|
248
|
+
// install generator ships the snippet commented. Without the meta: no global
|
|
249
|
+
// handle at all, and #perform short-circuits on the null sessionStorage read —
|
|
250
|
+
// zero production surface. The `?.content` chain is fully defensive (a stubbed
|
|
251
|
+
// document with no querySelector, a missing meta) so bootstrap never throws.
|
|
252
|
+
function attachLatencyHandle() {
|
|
253
|
+
if (typeof window === "undefined" || typeof document === "undefined") return
|
|
254
|
+
const env = document.querySelector?.('meta[name="phlex-reactive-env"]')?.content
|
|
255
|
+
if (env !== "development") return
|
|
256
|
+
window.PhlexReactive = { enableLatencySim, disableLatencySim }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Test seam: forget the one-time active-sim banner so the next test re-warns.
|
|
260
|
+
export function __resetReactiveLatencyForTest() {
|
|
261
|
+
latencyBannerShown = false
|
|
262
|
+
}
|
|
263
|
+
|
|
76
264
|
export function registerReactiveActions() {
|
|
77
265
|
registerReactiveVisit()
|
|
78
266
|
registerReactiveToken()
|
|
267
|
+
registerReactiveJs()
|
|
268
|
+
registerReactiveDismiss()
|
|
269
|
+
registerReactiveOffline()
|
|
270
|
+
attachLatencyHandle()
|
|
79
271
|
}
|
|
80
272
|
|
|
81
273
|
// Escape a DOM id for safe interpolation into a RegExp (an id can legally contain
|
|
@@ -133,6 +325,165 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
|
133
325
|
}
|
|
134
326
|
}
|
|
135
327
|
|
|
328
|
+
// The interpret-time attribute-name allowlist (issue #96) — the SECOND half of
|
|
329
|
+
// the two-sided default-deny. The Ruby builder already refuses these at build
|
|
330
|
+
// time; this guards a hand-built / forged ops attr from bypassing it. Refused:
|
|
331
|
+
// event handlers (on*, XSS), URL-bearing names (a javascript: navigation
|
|
332
|
+
// surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
|
|
333
|
+
const REFUSED_ATTR_URL = new Set(["href", "src", "srcdoc", "action", "formaction", "xlink:href", "style"])
|
|
334
|
+
function attrRefused(name) {
|
|
335
|
+
const lower = String(name).toLowerCase()
|
|
336
|
+
return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Run an animated visibility change (issue #96 `transition:`). `flip` performs
|
|
340
|
+
// the actual hidden-flag change; `[during, from, to]` are class lists applied
|
|
341
|
+
// AROUND it. Cleanup (removing during+to) is awaited via `animationend` OR a
|
|
342
|
+
// setTimeout fallback — whichever comes first — so an element with NO animation
|
|
343
|
+
// never leaves the helper classes stuck (the op chain itself is not blocked:
|
|
344
|
+
// cleanup is fire-and-forget, later ops run immediately). The fallback and the
|
|
345
|
+
// listener share a one-shot `done` guard so cleanup runs exactly once.
|
|
346
|
+
function runTransition(el, transition, flip) {
|
|
347
|
+
const [during, from, to] = transition
|
|
348
|
+
el.classList.add(during, from)
|
|
349
|
+
flip()
|
|
350
|
+
requestAnimationFrame(() => {
|
|
351
|
+
el.classList.remove(from)
|
|
352
|
+
el.classList.add(to)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
let done = false
|
|
356
|
+
const cleanup = () => {
|
|
357
|
+
if (done) return
|
|
358
|
+
done = true
|
|
359
|
+
el.classList.remove(during, to)
|
|
360
|
+
}
|
|
361
|
+
el.addEventListener("animationend", cleanup, { once: true })
|
|
362
|
+
// ~10% over a common 300ms transition; also the ONLY path for a non-animated
|
|
363
|
+
// element (animationend never fires there), so it must always be scheduled.
|
|
364
|
+
setTimeout(cleanup, 350)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors
|
|
368
|
+
// Phlex::Reactive::JS's vocabulary; an op name not in this map is
|
|
369
|
+
// warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
|
|
370
|
+
// ops attr must never break the page). Each op is a pure, local DOM mutation:
|
|
371
|
+
// nothing is read back, nothing is sent anywhere. Frozen so nothing can be
|
|
372
|
+
// registered into it at runtime — extending the vocabulary is a gem change,
|
|
373
|
+
// not an app hook.
|
|
374
|
+
const CLIENT_OPS = Object.freeze({
|
|
375
|
+
show: (el, args) => setHidden(el, false, args),
|
|
376
|
+
hide: (el, args) => setHidden(el, true, args),
|
|
377
|
+
toggle: (el, args) => setHidden(el, !el.hidden, args),
|
|
378
|
+
add_class: (el, args) => el.classList.add(...(args.classes ?? [])),
|
|
379
|
+
remove_class: (el, args) => el.classList.remove(...(args.classes ?? [])),
|
|
380
|
+
toggle_class: (el, args) => (args.classes ?? []).forEach((c) => el.classList.toggle(c)),
|
|
381
|
+
|
|
382
|
+
// Attribute ops (issue #96), interpret-time allowlisted. set_attr writes the
|
|
383
|
+
// (already-stringified) value; toggle_attr adds a missing attr (value "") or
|
|
384
|
+
// removes a present one; remove_attr removes it. A refused name warns + skips.
|
|
385
|
+
set_attr: (el, args) => {
|
|
386
|
+
if (guardAttr(args.name)) el.setAttribute(args.name, args.value ?? "")
|
|
387
|
+
},
|
|
388
|
+
remove_attr: (el, args) => {
|
|
389
|
+
if (guardAttr(args.name)) el.removeAttribute(args.name)
|
|
390
|
+
},
|
|
391
|
+
toggle_attr: (el, args) => {
|
|
392
|
+
if (!guardAttr(args.name)) return
|
|
393
|
+
if (el.hasAttribute(args.name)) el.removeAttribute(args.name)
|
|
394
|
+
else el.setAttribute(args.name, "")
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
// Focus ops (issue #96). focus targets the match itself; focus_first targets
|
|
398
|
+
// its first focusable descendant (opened-menu → first menuitem).
|
|
399
|
+
focus: (el) => el.focus?.(),
|
|
400
|
+
focus_first: (el) => firstFocusable(el)?.focus?.(),
|
|
401
|
+
|
|
402
|
+
// Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
|
|
403
|
+
// controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
|
|
404
|
+
dispatch: (el, args) => {
|
|
405
|
+
el.dispatchEvent(new CustomEvent(args.name, { bubbles: true, composed: true, detail: args.detail ?? {} }))
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// Apply a hidden-flag change, optionally animated by a [during, from, to]
|
|
410
|
+
// transition (issue #96). Split out so show/hide/toggle share it.
|
|
411
|
+
function setHidden(el, hidden, args) {
|
|
412
|
+
if (args?.transition) runTransition(el, args.transition, () => (el.hidden = hidden))
|
|
413
|
+
else el.hidden = hidden
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// The interpret-time attribute guard: refuse (warn + skip) a name off the
|
|
417
|
+
// allowlist. Returns true when the op may proceed.
|
|
418
|
+
function guardAttr(name) {
|
|
419
|
+
if (!attrRefused(name)) return true
|
|
420
|
+
console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(name)} — skipped`)
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The first focusable descendant of `el`, in document order — the natural
|
|
425
|
+
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
426
|
+
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
427
|
+
// nothing inside is focusable (focus_first then no-ops).
|
|
428
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
429
|
+
function firstFocusable(el) {
|
|
430
|
+
return el.querySelectorAll?.(FOCUSABLE)?.[0] ?? null
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Parse a [[name, args], ...] op list from a raw attr/param. An array passes
|
|
434
|
+
// through; a JSON string is parsed; anything malformed degrades to [] — a bad
|
|
435
|
+
// ops attr must NEVER break the page (client-side default-deny). Shared by the
|
|
436
|
+
// controller's runOps and the reactive:js stream action (issue #97).
|
|
437
|
+
function parseOps(raw) {
|
|
438
|
+
if (Array.isArray(raw)) return raw
|
|
439
|
+
if (typeof raw !== "string") return []
|
|
440
|
+
try {
|
|
441
|
+
const list = JSON.parse(raw)
|
|
442
|
+
return Array.isArray(list) ? list : []
|
|
443
|
+
} catch {
|
|
444
|
+
return []
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Interpret a [[name, args], ...] op list against the frozen CLIENT_OPS
|
|
449
|
+
// whitelist (issues #95/#96/#97). `resolveTargets(args)` returns the element(s)
|
|
450
|
+
// an op applies to — the controller scopes to its root (excluding nested
|
|
451
|
+
// reactive roots); the reactive:js stream action scopes to its target root (or
|
|
452
|
+
// the document). An unknown name warns and is SKIPPED while the rest of the
|
|
453
|
+
// chain still applies — client-side default-deny, one bad op never takes down
|
|
454
|
+
// its siblings. Object.hasOwn (not a bare read) so inherited Object members
|
|
455
|
+
// ("constructor") can't masquerade as ops.
|
|
456
|
+
function applyOps(list, resolveTargets) {
|
|
457
|
+
for (const entry of list) {
|
|
458
|
+
if (!Array.isArray(entry)) continue
|
|
459
|
+
const [name, args = {}] = entry
|
|
460
|
+
if (!Object.hasOwn(CLIENT_OPS, name)) {
|
|
461
|
+
console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
for (const el of resolveTargets(args)) CLIENT_OPS[name](el, args)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Resolve a reactive:js op's targets against its `target` root (issue #97).
|
|
469
|
+
// "@root" is the root element itself; a selector resolves WITHIN it; a bare
|
|
470
|
+
// selector with no root (no `target` attr on the stream) resolves document-wide
|
|
471
|
+
// — a broadcast op anchored by a global selector (#bell) rather than a
|
|
472
|
+
// component. Unlike the controller path there is no nested-reactive-root
|
|
473
|
+
// ownership filter: a server-pushed op names its own scope explicitly.
|
|
474
|
+
function streamOpTargets(args, root) {
|
|
475
|
+
const to = args.to
|
|
476
|
+
if (root) {
|
|
477
|
+
if (to === "@root") return [root]
|
|
478
|
+
if (typeof to !== "string" || to === "") return []
|
|
479
|
+
return [...root.querySelectorAll(to)]
|
|
480
|
+
}
|
|
481
|
+
// No target root: document-scoped. "@root" is meaningless here (nothing to
|
|
482
|
+
// anchor to) → no-op; a selector matches document-wide.
|
|
483
|
+
if (typeof to !== "string" || to === "" || to === "@root") return []
|
|
484
|
+
return [...document.querySelectorAll(to)]
|
|
485
|
+
}
|
|
486
|
+
|
|
136
487
|
// Register this controller eagerly (not lazily) so a click immediately after
|
|
137
488
|
// page load is never missed. The phlex-reactive engine auto-pins it with
|
|
138
489
|
// preload: true for importmap apps; see the README for esbuild/webpack.
|
|
@@ -143,7 +494,21 @@ export default class extends Controller {
|
|
|
143
494
|
|
|
144
495
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
145
496
|
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
497
|
+
#throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
|
|
146
498
|
#actionPathCache // page-stable action path, resolved once per controller
|
|
499
|
+
#timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
|
|
500
|
+
#tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
|
|
501
|
+
// Loading-state bookkeeping (issue #99). All keyed so overlapping enqueues
|
|
502
|
+
// refcount correctly and never clobber each other:
|
|
503
|
+
#busyPending = 0 // root aria-busy pending counter (remove only at zero)
|
|
504
|
+
#busyActions = new Map() // action -> in-flight count (root's space-separated busy set + busy_on)
|
|
505
|
+
#busyTokenCounts = new WeakMap() // element -> Map(action -> count): its data-reactive-busy token set
|
|
506
|
+
#loadingSnapshots = new Map() // trigger element -> { count, disabled, text } refcounted snapshot
|
|
507
|
+
// Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the
|
|
508
|
+
// navigate-away guard handlers, held so disconnect() can remove exactly them.
|
|
509
|
+
#boundScanDirty
|
|
510
|
+
#boundBeforeUnload
|
|
511
|
+
#boundBeforeVisit
|
|
147
512
|
|
|
148
513
|
// Mark that a reactive controller actually connected, so the registration
|
|
149
514
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -165,14 +530,55 @@ export default class extends Controller {
|
|
|
165
530
|
"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README."
|
|
166
531
|
)
|
|
167
532
|
}
|
|
533
|
+
|
|
534
|
+
// Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
|
|
535
|
+
// reactive_field(dirty:)), so a component that never uses it pays nothing (no
|
|
536
|
+
// baseline scan, no morph listener on every broadcast). A plain (outerHTML)
|
|
537
|
+
// replace re-connects the controller, so seed the baseline scan here — the root
|
|
538
|
+
// reflects current-vs-default WITHOUT waiting for the first input. An in-place
|
|
539
|
+
// morph / broadcast morph keeps the element CONNECTED and fires no Stimulus
|
|
540
|
+
// lifecycle, so ALSO listen for turbo:morph-element on this.element to re-scan
|
|
541
|
+
// after the morph writes fresh default* attributes (reactive:applied is NOT a
|
|
542
|
+
// valid hook — it fires when streams are handed to Turbo, BEFORE the DOM
|
|
543
|
+
// mutation). Both listeners are torn down in disconnect().
|
|
544
|
+
if (this.#dirtyTrackingEnabled()) {
|
|
545
|
+
this.#boundScanDirty = () => this.#scanDirty()
|
|
546
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundScanDirty)
|
|
547
|
+
this.#scanDirty()
|
|
548
|
+
|
|
549
|
+
// warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read
|
|
550
|
+
// (never a cached snapshot — the count is re-derived from the DOM each time).
|
|
551
|
+
// beforeunload covers a real browser unload; turbo:before-visit covers a
|
|
552
|
+
// Turbo in-app navigation (it does NOT fire on restoration visits — the
|
|
553
|
+
// documented gap). Registered on window only when the marker is present.
|
|
554
|
+
if (this.element.getAttribute?.("data-reactive-warn-unsaved") === "true") {
|
|
555
|
+
this.#armUnsavedGuard()
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
|
|
561
|
+
// trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(
|
|
562
|
+
// dirty:) puts it on a descendant field. Either turns tracking on. A quick
|
|
563
|
+
// attribute read + one scoped query, evaluated once per connect (a cold path).
|
|
564
|
+
#dirtyTrackingEnabled() {
|
|
565
|
+
if ((this.element.getAttribute?.("data-action") ?? "").includes("reactive#trackDirty")) return true
|
|
566
|
+
const nodes = this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]') ?? []
|
|
567
|
+
for (const el of nodes) if (this.#ownsField(el)) return true
|
|
568
|
+
return false
|
|
168
569
|
}
|
|
169
570
|
|
|
170
571
|
// Tear down any pending debounce timers when the controller leaves the DOM
|
|
171
572
|
// (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
|
|
172
573
|
// fired yet would later call #enqueue on a disconnected controller — a round
|
|
173
574
|
// trip against a detached element / stale token (issue #17 follow-up).
|
|
575
|
+
// Throttle suppression timers (issue #80) are torn down the same way — a
|
|
576
|
+
// leading-edge timer holds no pending POST, but leaving it running would leak
|
|
577
|
+
// it past the element's life.
|
|
174
578
|
disconnect() {
|
|
175
579
|
this.#clearAllDebounces()
|
|
580
|
+
this.#clearAllThrottles()
|
|
581
|
+
this.#teardownDirtyTracking()
|
|
176
582
|
}
|
|
177
583
|
|
|
178
584
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
@@ -182,9 +588,31 @@ export default class extends Controller {
|
|
|
182
588
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
183
589
|
// it always uses the freshest token.
|
|
184
590
|
dispatch(event) {
|
|
185
|
-
|
|
591
|
+
// `window` (renamed: never shadow the global) and `outside` are the event-
|
|
592
|
+
// modifier params (issue #80). The client decides preventDefault behavior
|
|
593
|
+
// from event.params — set by the Ruby on() — never by sniffing the
|
|
594
|
+
// Stimulus descriptor.
|
|
595
|
+
const { action, params, debounce, throttle, confirm, outside, window: windowBound, optimistic, loading } =
|
|
596
|
+
event.params
|
|
186
597
|
if (!action) return
|
|
187
598
|
|
|
599
|
+
// Outside guard FIRST (issue #80): an outside: trigger only fires for
|
|
600
|
+
// events whose target is OUTSIDE this component's ROOT (containment against
|
|
601
|
+
// this.element — .contains includes the root itself). An event inside the
|
|
602
|
+
// root must be a COMPLETE no-op — before preventDefault (the page's native
|
|
603
|
+
// click behavior is untouched) and before the reactive:before-dispatch
|
|
604
|
+
// lifecycle event (nothing to announce, nothing to veto).
|
|
605
|
+
if (outside && this.element.contains(event.target)) return
|
|
606
|
+
|
|
607
|
+
// The trigger is event.currentTarget — the element on(...) was spread onto —
|
|
608
|
+
// NOT event.target (issue #99). A `<button><span>Save</span></button>` click
|
|
609
|
+
// has target === the span, which carries no params and is the wrong element
|
|
610
|
+
// to disable / swap text on. currentTarget is the bound element; fall back to
|
|
611
|
+
// target for a directly-invoked/synthetic event. Captured now because
|
|
612
|
+
// #proceed runs in a later microtask (after the confirm resolver), by which
|
|
613
|
+
// point currentTarget is reset to null.
|
|
614
|
+
const target = event.currentTarget ?? event.target
|
|
615
|
+
|
|
188
616
|
// Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
|
|
189
617
|
// within the event dispatch — BEFORE the (possibly async) confirm gate below.
|
|
190
618
|
// preventDefault() only works while the event is still being handled; once we
|
|
@@ -194,14 +622,22 @@ export default class extends Controller {
|
|
|
194
622
|
// for debounced triggers too — the round trip is deferred, but the native
|
|
195
623
|
// default must still be prevented now. (Moved ahead of the confirm branch in
|
|
196
624
|
// issue #55: an async resolver means we can't preventDefault after awaiting.)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
625
|
+
//
|
|
626
|
+
// ONLY for element-bound triggers: a window-bound trigger (window:/outside:,
|
|
627
|
+
// issue #80) hears EVERY matching event on the page — preventDefault-ing
|
|
628
|
+
// those would kill every link click while a dropdown is mounted. The page's
|
|
629
|
+
// native behavior proceeds alongside the reactive round trip.
|
|
630
|
+
//
|
|
631
|
+
// The `checked: :keep` optimistic hint (issue #98) OPTS OUT: for a click-bound
|
|
632
|
+
// checkbox/radio the unconditional preventDefault is exactly what stops the
|
|
633
|
+
// native flip from happening before the morph — so a bare checkbox click
|
|
634
|
+
// (which has no form-navigation default to lose) skips it and flips now, and
|
|
635
|
+
// the failure revert snaps it back. A `change`-bound trigger is unaffected —
|
|
636
|
+
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
637
|
+
if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
202
638
|
|
|
203
639
|
// No confirm message → proceed straight away (unchanged fast path).
|
|
204
|
-
if (!confirm) return this.#proceed(target, action, params, debounce)
|
|
640
|
+
if (!confirm) return this.#proceed(target, action, params, debounce, throttle, optimistic, loading)
|
|
205
641
|
|
|
206
642
|
// Confirmation gate (issue #52, made overridable + async in #55). A reactive
|
|
207
643
|
// trigger can't use Hotwire's data-turbo-confirm — this controller preempts
|
|
@@ -218,10 +654,44 @@ export default class extends Controller {
|
|
|
218
654
|
.then(() => confirmResolver(confirm))
|
|
219
655
|
.catch(() => false)
|
|
220
656
|
.then((ok) => {
|
|
221
|
-
if (ok) this.#proceed(target, action, params, debounce)
|
|
657
|
+
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, loading)
|
|
222
658
|
})
|
|
223
659
|
}
|
|
224
660
|
|
|
661
|
+
// CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling
|
|
662
|
+
// of dispatch(). Wired by on_client: applies the declared op chain
|
|
663
|
+
// (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,
|
|
664
|
+
// NO params, NO fetch, ever. Ops are ephemeral UI: any server re-render of
|
|
665
|
+
// the component resets whatever they toggled (by design — a signed action
|
|
666
|
+
// owns state that must survive re-renders).
|
|
667
|
+
runOps(event) {
|
|
668
|
+
const { ops, outside, window: windowBound } = event.params
|
|
669
|
+
|
|
670
|
+
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
671
|
+
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
672
|
+
// preventDefault and before any op runs.
|
|
673
|
+
if (outside && this.element.contains(event.target)) return
|
|
674
|
+
|
|
675
|
+
// Element-bound triggers preventDefault (a bare button inside a <form>
|
|
676
|
+
// must not submit it); window-bound triggers (window:/outside:) never do —
|
|
677
|
+
// they hear every matching event on the page, and preventDefault-ing those
|
|
678
|
+
// would kill native clicks site-wide (issue #80 rationale).
|
|
679
|
+
if (!windowBound) event.preventDefault()
|
|
680
|
+
|
|
681
|
+
this.#applyOps(this.#parseOps(ops))
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
|
|
685
|
+
// reactive_root(track_dirty: true): an `input` on an owned field runs a FULL
|
|
686
|
+
// re-scan of every field this root owns. NO round trip, NO shipped state — the
|
|
687
|
+
// baseline is the DOM's own defaultValue/defaultChecked/defaultSelected (the
|
|
688
|
+
// last server render). A full pass (not a per-target toggle) is essential for
|
|
689
|
+
// radio groups: the deselected radio flips to checked=false and fires no input
|
|
690
|
+
// event, so only re-scanning everything keeps its flag honest.
|
|
691
|
+
trackDirty() {
|
|
692
|
+
this.#scanDirty()
|
|
693
|
+
}
|
|
694
|
+
|
|
225
695
|
// Client-side compute (data binding). Wired by reactive_compute: an `input`
|
|
226
696
|
// trigger (input->reactive#recompute) runs a REGISTERED JS reducer over the
|
|
227
697
|
// named input fields and writes the named output fields WITH NO ROUND TRIP —
|
|
@@ -232,25 +702,111 @@ export default class extends Controller {
|
|
|
232
702
|
// Reads inputs/outputs/reducer from the root's data-reactive-compute-* attrs
|
|
233
703
|
// (set once by reactive_compute_attrs). A missing/unregistered reducer is a
|
|
234
704
|
// no-op — a page must never break because a binding wasn't wired up.
|
|
235
|
-
|
|
705
|
+
//
|
|
706
|
+
// The reducer gets a second argument, meta = { changed } (issue #75): the
|
|
707
|
+
// name of the declared input the triggering event edited, or null for a
|
|
708
|
+
// direct call / an unowned or undeclared target. A multi-way rebalance
|
|
709
|
+
// branches on it (edit c → derive a; else → derive c). Note that a #76
|
|
710
|
+
// output write dispatches a real input event, so recompute RE-ENTERS with
|
|
711
|
+
// changed = that output's name — the reducer must be convergent (see
|
|
712
|
+
// compute.js) so the change guard settles the chain.
|
|
713
|
+
recompute(event) {
|
|
714
|
+
// Inputs may be a JSON ARRAY of names (array form — every input coerced
|
|
715
|
+
// through Number, the shipped behavior) or a JSON OBJECT of name→type (hash
|
|
716
|
+
// form, issue #104 — :number coerced, :string read raw). #parseComputeInputs
|
|
717
|
+
// returns [name, type] pairs either way (array form defaults type "number").
|
|
718
|
+
const inputPairs = this.#parseComputeInputs()
|
|
719
|
+
const inputs = inputPairs.map(([name]) => name)
|
|
720
|
+
|
|
721
|
+
// Resolve every declared input AND output through ONE per-call resolver whose
|
|
722
|
+
// ownership probe is computed ONCE (issue #117), replacing the per-name
|
|
723
|
+
// closest() walk #ownedField did on every read — a 30-field calculator paid
|
|
724
|
+
// ~60 closest() sweeps per keystroke. #ownershipFilter returns a constant-true
|
|
725
|
+
// predicate in the common no-nested-root case (skipping closest() entirely)
|
|
726
|
+
// and the exact #ownsField check when a nested reactive root is present
|
|
727
|
+
// (issue #15 scoping, byte-identical to before). Resolution is memoized in a
|
|
728
|
+
// per-CALL Map, FIRST-WINS, so a name read as an input AND written as an
|
|
729
|
+
// output resolves to the SAME element and is queried once.
|
|
730
|
+
//
|
|
731
|
+
// Why per-name `[name="X"]` queries and not one bare `[name]` sweep: a single
|
|
732
|
+
// sweep is the natural "one walk", but the resolver must issue the SAME
|
|
733
|
+
// per-name query shape the field walk always has (the issue-#15 unit fakes
|
|
734
|
+
// answer only `[name="X"]`). It is O(distinct declared names) queries, not the
|
|
735
|
+
// old O(inputs + outputs) — the ownership decision is hoisted out of the loop.
|
|
736
|
+
// The memo is per-CALL only: an output write dispatches `input` (issue #76),
|
|
737
|
+
// re-entering recompute, which correctly rebuilds a fresh map (a morph may
|
|
738
|
+
// have replaced the nodes) — it is NEVER stored on the instance.
|
|
739
|
+
const owns = this.#ownershipFilter()
|
|
740
|
+
const byName = new Map()
|
|
741
|
+
const ownedField = (name) => {
|
|
742
|
+
if (byName.has(name)) return byName.get(name)
|
|
743
|
+
let found = null
|
|
744
|
+
for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
|
|
745
|
+
if (owns(el)) {
|
|
746
|
+
found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
|
|
747
|
+
break
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
byName.set(name, found)
|
|
751
|
+
return found
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Identity-mirror pass (issue #104), ALWAYS run — even with NO registered
|
|
755
|
+
// reducer, so reactive_text(:title) mirrors a field into its text node with
|
|
756
|
+
// zero reducer wiring. Each declared input's RAW string value is written to
|
|
757
|
+
// its owned [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
|
|
758
|
+
// early-return below so a reducer-less binding still mirrors.
|
|
759
|
+
for (const name of inputs) this.#mirrorText(name, ownedField(name)?.value ?? "")
|
|
760
|
+
|
|
236
761
|
const key = this.element.getAttribute("data-reactive-compute-reducer-param")
|
|
237
762
|
if (!key) return
|
|
238
763
|
const reduce = computeReducer(key)
|
|
239
764
|
if (!reduce) return
|
|
240
765
|
|
|
241
|
-
const inputs = this.#parseComputeList("data-reactive-compute-inputs-param")
|
|
242
766
|
const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
|
|
243
767
|
|
|
768
|
+
// Coerce each input per its declared type (issue #104): "string" → the raw
|
|
769
|
+
// display string (blank/absent → ""); else ("number", the array-form default)
|
|
770
|
+
// → the numeric coercion (blank/NaN → 0, the nanToZero the hand-written
|
|
771
|
+
// calculators use). Reads from the memoized resolver — no re-query.
|
|
244
772
|
const values = {}
|
|
245
|
-
for (const name of
|
|
773
|
+
for (const [name, type] of inputPairs) {
|
|
774
|
+
const field = ownedField(name)
|
|
775
|
+
if (type === "string") {
|
|
776
|
+
values[name] = field?.value ?? ""
|
|
777
|
+
} else {
|
|
778
|
+
const n = Number(field?.value)
|
|
779
|
+
values[name] = Number.isFinite(n) ? n : 0
|
|
780
|
+
}
|
|
781
|
+
}
|
|
246
782
|
|
|
247
|
-
|
|
783
|
+
// meta.changed stays on #changedComputeField (its own #ownsField check over
|
|
784
|
+
// the raw event target) — NOT this resolver. The issue-#15 nested-rejection
|
|
785
|
+
// test depends on that path being unchanged.
|
|
786
|
+
const result = reduce(values, { changed: this.#changedComputeField(event, inputs) }) || {}
|
|
248
787
|
for (const name of outputs) {
|
|
249
788
|
if (!(name in result)) continue
|
|
250
|
-
const field =
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
if (field)
|
|
789
|
+
const field = ownedField(name)
|
|
790
|
+
// Output resolution (issue #104): write to the owned named FIELD if one
|
|
791
|
+
// exists, ELSE mirror to every owned [data-reactive-text="<name>"] node.
|
|
792
|
+
if (field) {
|
|
793
|
+
// Real browsers do NOT fire `input` on a programmatic .value write (issue
|
|
794
|
+
// #76), so after writing we dispatch a bubbling `input` ourselves — that's
|
|
795
|
+
// what drives a chained repaint (a summary listener, a second compute),
|
|
796
|
+
// matching the server's set_value + dispatch("input") contract. The write
|
|
797
|
+
// is CHANGE-GUARDED: an unchanged value is skipped entirely (no write, no
|
|
798
|
+
// event). The guard is what lets a reducer with overlapping inputs/outputs
|
|
799
|
+
// (the shipped payment_split shape) settle — an unconditional dispatch
|
|
800
|
+
// would re-enter input->reactive#recompute forever.
|
|
801
|
+
if (String(result[name]) === field.value) continue
|
|
802
|
+
field.value = result[name]
|
|
803
|
+
field.dispatchEvent(new Event("input", { bubbles: true }))
|
|
804
|
+
} else {
|
|
805
|
+
// A text-node output: textContent, XSS-safe by construction. Change-
|
|
806
|
+
// guarded too (compare before writing), but NO input dispatch — a text
|
|
807
|
+
// node has no listener contract, so nothing chains off it.
|
|
808
|
+
this.#mirrorText(name, result[name])
|
|
809
|
+
}
|
|
254
810
|
}
|
|
255
811
|
}
|
|
256
812
|
|
|
@@ -306,15 +862,18 @@ export default class extends Controller {
|
|
|
306
862
|
// per the selector on data-reactive-listnav-option-param. The attr rides on the
|
|
307
863
|
// TRIGGER element (the search input on(...) is spread onto), read from the
|
|
308
864
|
// event; the options are still scoped to this controller's root. Empty when
|
|
309
|
-
// unset. Falls back to the root for a directly-invoked call (unit tests).
|
|
865
|
+
// unset. Falls back to the root for a directly-invoked call (unit tests). The
|
|
866
|
+
// ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
|
|
867
|
+
// no-nested-root case it is a constant true, skipping a closest() walk per
|
|
868
|
+
// option.
|
|
310
869
|
#listnavOptions(event) {
|
|
311
870
|
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
312
871
|
const selector =
|
|
313
872
|
trigger.getAttribute?.("data-reactive-listnav-option-param") ??
|
|
314
873
|
this.element.getAttribute("data-reactive-listnav-option-param")
|
|
315
874
|
if (!selector) return []
|
|
316
|
-
const
|
|
317
|
-
return Array.from(
|
|
875
|
+
const owns = this.#ownershipFilter()
|
|
876
|
+
return Array.from(this.element.querySelectorAll(selector)).filter(owns)
|
|
318
877
|
}
|
|
319
878
|
|
|
320
879
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
@@ -330,50 +889,125 @@ export default class extends Controller {
|
|
|
330
889
|
}
|
|
331
890
|
}
|
|
332
891
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
892
|
+
// Parse the inputs param into [name, type] pairs (issue #104). The wire is a
|
|
893
|
+
// JSON ARRAY of names (array form → every input typed "number", the shipped
|
|
894
|
+
// numeric coercion) OR a JSON OBJECT of name→type (hash form → ":string" read
|
|
895
|
+
// raw, ":number" coerced). Malformed/absent degrades to [] — a bad binding
|
|
896
|
+
// must never throw on input.
|
|
897
|
+
#parseComputeInputs() {
|
|
898
|
+
const raw = this.element.getAttribute("data-reactive-compute-inputs-param")
|
|
899
|
+
if (!raw) return []
|
|
900
|
+
try {
|
|
901
|
+
const parsed = JSON.parse(raw)
|
|
902
|
+
if (Array.isArray(parsed)) return parsed.map((name) => [name, "number"])
|
|
903
|
+
if (parsed && typeof parsed === "object") return Object.entries(parsed)
|
|
904
|
+
return []
|
|
905
|
+
} catch {
|
|
906
|
+
return []
|
|
907
|
+
}
|
|
339
908
|
}
|
|
340
909
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
#
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
910
|
+
// The declared compute input the event just edited — the reducer's
|
|
911
|
+
// meta.changed (issue #75). The triggering field counts only when it is a
|
|
912
|
+
// named form control OWNED by this root (not a nested reactive root's, issue
|
|
913
|
+
// #15) AND its name is among the declared compute inputs; anything else
|
|
914
|
+
// (a direct call, an unowned/undeclared target) yields null.
|
|
915
|
+
#changedComputeField(event, inputs) {
|
|
916
|
+
const target = event?.target
|
|
917
|
+
if (!target?.name || typeof target.closest !== "function") return null
|
|
918
|
+
if (!inputs.includes(target.name)) return null
|
|
919
|
+
return this.#ownsField(target) ? target.name : null
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Write `value` into every owned [data-reactive-text="<name>"] node via
|
|
923
|
+
// textContent (issue #104) — XSS-safe by construction (never innerHTML). Drives
|
|
924
|
+
// both the identity mirror (an input's raw value) and a text-node output (a
|
|
925
|
+
// reducer result with no matching field). Change-guarded (skip an unchanged
|
|
926
|
+
// node) and NO input dispatch — a text node has no listener contract. String()
|
|
927
|
+
// so a numeric result renders like the DOM would.
|
|
928
|
+
#mirrorText(name, value) {
|
|
929
|
+
const text = String(value)
|
|
930
|
+
for (const node of this.#ownedTextNodes(name)) {
|
|
931
|
+
if (node.textContent === text) continue
|
|
932
|
+
node.textContent = text
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Every [data-reactive-text="<name>"] mirror OWNED by this root (skips nested
|
|
937
|
+
// reactive roots, issue #15). Empty when none — reactive_text is optional.
|
|
938
|
+
#ownedTextNodes(name) {
|
|
939
|
+
const nodes = this.element.querySelectorAll(`[data-reactive-text="${name}"]`)
|
|
940
|
+
return Array.from(nodes).filter((el) => this.#ownsField(el))
|
|
348
941
|
}
|
|
349
942
|
|
|
350
943
|
// Enqueue the action — debounced if a debounce window is set, else immediately.
|
|
351
944
|
// Split out of dispatch so both the no-confirm fast path and the post-confirm
|
|
352
945
|
// microtask share one place (issue #55). `target` is captured up front because
|
|
353
946
|
// this can run in a later microtask, after event.target has been reset.
|
|
354
|
-
#proceed(target, action, params, debounce) {
|
|
947
|
+
#proceed(target, action, params, debounce, throttle, optimistic, loading) {
|
|
948
|
+
// Lifecycle veto point (issue #79): one cancelable reactive:before-dispatch
|
|
949
|
+
// per user gesture — post-preventDefault, post-confirm, PRE-debounce (and
|
|
950
|
+
// PRE-throttle, the same timing). event.preventDefault() skips the
|
|
951
|
+
// debounce/throttle AND the enqueue entirely (nothing is scheduled).
|
|
952
|
+
// retry() re-enters the queue directly, so this does NOT refire on a retry.
|
|
953
|
+
// detail.params are the trigger's explicit params; sibling fields are
|
|
954
|
+
// collected later, at send time.
|
|
955
|
+
const before = this.#emit("reactive:before-dispatch", {
|
|
956
|
+
action,
|
|
957
|
+
params: this.#parseParams(params),
|
|
958
|
+
element: this.element,
|
|
959
|
+
}, { cancelable: true })
|
|
960
|
+
if (before.defaultPrevented) return
|
|
961
|
+
|
|
355
962
|
// Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
|
|
356
963
|
// coalesce rapid events into ONE round trip after a quiet period, instead of
|
|
357
964
|
// one POST per keystroke (issue #17). A blur flushes a pending dispatch.
|
|
965
|
+
// The optimistic hint (issue #98) and the loading state (issue #99) ride to
|
|
966
|
+
// the flush too, so they apply ONCE per enqueue — a debounced input must not
|
|
967
|
+
// flap toggle_class per keystroke, and its element must NOT be disabled
|
|
968
|
+
// during the quiet period (that would break typing). Both apply at ENQUEUE.
|
|
358
969
|
const ms = Number(debounce) || 0
|
|
359
|
-
if (ms > 0) return this.#debounceDispatch(target, ms, action, params)
|
|
360
|
-
|
|
970
|
+
if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, loading)
|
|
971
|
+
|
|
972
|
+
// Throttled trigger (e.g. on(:track, event: "scroll", window: true,
|
|
973
|
+
// throttle: 250), issue #80): LEADING-EDGE rate limit — fire the first
|
|
974
|
+
// event immediately, drop the rest until the window elapses. debounce and
|
|
975
|
+
// throttle are mutually exclusive (the Ruby on() raises on both).
|
|
976
|
+
const throttleMs = Number(throttle) || 0
|
|
977
|
+
if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, loading)
|
|
978
|
+
|
|
979
|
+
return this.#enqueue(action, params, optimistic, target, loading)
|
|
361
980
|
}
|
|
362
981
|
|
|
363
|
-
|
|
364
|
-
|
|
982
|
+
// Apply the optimistic hint ONCE (recording its inverse) and chain the round
|
|
983
|
+
// trip, threading that inverse onto THIS queued request so the serialized
|
|
984
|
+
// per-controller queue reverts the RIGHT request's hint on failure (issue
|
|
985
|
+
// #98). Applying here — the single flush/enqueue point every path funnels
|
|
986
|
+
// through — is what makes a hint apply once per enqueue, not per raw dispatch.
|
|
987
|
+
//
|
|
988
|
+
// The loading state (issue #99) applies here too, for the same reason: enqueue
|
|
989
|
+
// is the moment the request is committed to the queue, so the always-on busy
|
|
990
|
+
// vocabulary (data-reactive-busy on the trigger + root, aria-busy via a pending
|
|
991
|
+
// counter, busy_on scoping) and the loading hint (disable + class + text swap)
|
|
992
|
+
// cover the WHOLE pending window — queue wait included — not just the fetch. It
|
|
993
|
+
// returns a `settle` closure that #perform runs in its finally (success OR
|
|
994
|
+
// failure), guarded so a morph-replaced trigger is never clobbered.
|
|
995
|
+
#enqueue(action, params, optimistic, target, loading) {
|
|
996
|
+
const inverse = this.#applyOptimistic(optimistic, target)
|
|
997
|
+
const settle = this.#applyLoading(action, target, loading)
|
|
998
|
+
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params, inverse, settle))
|
|
365
999
|
return this.queue
|
|
366
1000
|
}
|
|
367
1001
|
|
|
368
1002
|
// Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
|
|
369
1003
|
// Also flush immediately on blur so leaving the field never drops the last
|
|
370
1004
|
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
371
|
-
#debounceDispatch(target, ms, action, params) {
|
|
1005
|
+
#debounceDispatch(target, ms, action, params, optimistic, loading) {
|
|
372
1006
|
this.#clearDebounce(target)
|
|
373
1007
|
|
|
374
1008
|
const flush = () => {
|
|
375
1009
|
this.#clearDebounce(target)
|
|
376
|
-
this.#enqueue(action, params)
|
|
1010
|
+
this.#enqueue(action, params, optimistic, target, loading)
|
|
377
1011
|
}
|
|
378
1012
|
const timer = setTimeout(flush, ms)
|
|
379
1013
|
target?.addEventListener?.("blur", flush, { once: true })
|
|
@@ -395,13 +1029,197 @@ export default class extends Controller {
|
|
|
395
1029
|
for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
|
|
396
1030
|
}
|
|
397
1031
|
|
|
398
|
-
|
|
1032
|
+
// Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
|
|
1033
|
+
// event fires immediately; a suppression timer then drops further events
|
|
1034
|
+
// until the window elapses (no trailing fire — dropped, not queued). Timers
|
|
1035
|
+
// are keyed on action + target, NOT target alone: window-bound scroll/resize
|
|
1036
|
+
// events all share event.target === document, so two window-bound triggers
|
|
1037
|
+
// on one component would otherwise collide on one timer.
|
|
1038
|
+
#throttleDispatch(target, ms, action, params, optimistic, loading) {
|
|
1039
|
+
const timers = this.#throttleTimers.get(target) ?? new Map()
|
|
1040
|
+
if (timers.has(action)) return // inside the window — suppress
|
|
1041
|
+
|
|
1042
|
+
const timer = setTimeout(() => {
|
|
1043
|
+
timers.delete(action)
|
|
1044
|
+
if (timers.size === 0) this.#throttleTimers.delete(target)
|
|
1045
|
+
}, ms)
|
|
1046
|
+
timers.set(action, timer)
|
|
1047
|
+
this.#throttleTimers.set(target, timers)
|
|
1048
|
+
return this.#enqueue(action, params, optimistic, target, loading) // leading edge: fire NOW
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Clear every throttle suppression timer (used on disconnect, alongside
|
|
1052
|
+
// #clearAllDebounces) so nothing outlives the element.
|
|
1053
|
+
#clearAllThrottles() {
|
|
1054
|
+
for (const timers of this.#throttleTimers.values()) {
|
|
1055
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
1056
|
+
}
|
|
1057
|
+
this.#throttleTimers.clear()
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Raw-dispatch a lifecycle CustomEvent (issue #79). Deliberately NOT
|
|
1061
|
+
// Stimulus's this.dispatch() helper — that name is SHADOWED by this
|
|
1062
|
+
// controller's own dispatch(event) action method. Bubbling + composed so a
|
|
1063
|
+
// page-level listener (or `data-action="reactive:error->toast#show"` on an
|
|
1064
|
+
// ancestor) hears it. After a plain (non-morph) replace this.element is a
|
|
1065
|
+
// DETACHED node — a bubbling event on it never reaches document listeners —
|
|
1066
|
+
// so fall back to dispatching on document itself.
|
|
1067
|
+
#emit(name, detail, { cancelable = false } = {}) {
|
|
1068
|
+
const event = new CustomEvent(name, { bubbles: true, composed: true, cancelable, detail })
|
|
1069
|
+
const root = this.element.isConnected ? this.element : document
|
|
1070
|
+
root.dispatchEvent(event)
|
|
1071
|
+
return event
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// reactive:error detail: { action, params, kind, status?, body?, retry }.
|
|
1075
|
+
// `params` are the FULL params that were sent (collected fields + explicit
|
|
1076
|
+
// trigger params). retry() re-enters the request queue with the ORIGINAL raw
|
|
1077
|
+
// trigger params, so #perform re-reads the freshest token and RE-COLLECTS the
|
|
1078
|
+
// sibling fields — nothing stale is replayed. It does not refire
|
|
1079
|
+
// reactive:before-dispatch (one veto per user gesture), and it no-ops with a
|
|
1080
|
+
// warning once the root has left the DOM (retrying against a detached
|
|
1081
|
+
// element would post a stale token into nowhere).
|
|
1082
|
+
#emitError(action, rawParams, sentParams, extra) {
|
|
1083
|
+
const retry = () => {
|
|
1084
|
+
if (!this.element.isConnected) {
|
|
1085
|
+
console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM")
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
return this.#enqueue(action, rawParams)
|
|
1089
|
+
}
|
|
1090
|
+
this.#emit("reactive:error", { action, params: sentParams, ...extra, retry })
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Mark the reactive root as errored (issue #100) with the failure kind, so an
|
|
1094
|
+
// app can style it purely in CSS ([data-reactive-error] { … }) with zero JS.
|
|
1095
|
+
// Guarded — a plain replace may have detached the root before the failure lands.
|
|
1096
|
+
#markError(kind) {
|
|
1097
|
+
if (this.element?.isConnected === false) return
|
|
1098
|
+
this.element?.setAttribute?.("data-reactive-error", kind)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Clear the failure marker on the next successful apply (issue #100).
|
|
1102
|
+
#clearError() {
|
|
1103
|
+
this.element?.removeAttribute?.("data-reactive-error")
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Offline fallback (issue #100): a network failure reached no server, so there
|
|
1107
|
+
// is no body to render. Clone the content of a server-rendered
|
|
1108
|
+
// <template data-reactive-error-flash> into the flash region so the user still
|
|
1109
|
+
// sees SOMETHING. The template is app-authored (trusted) — this is a pure
|
|
1110
|
+
// deep clone, never client templating of untrusted data. No template, or no
|
|
1111
|
+
// flash container, is a silent no-op (a page without the opt-in is unchanged).
|
|
1112
|
+
#renderNetworkFallback() {
|
|
1113
|
+
const template = document.querySelector("[data-reactive-error-flash]")
|
|
1114
|
+
if (!template?.content) return
|
|
1115
|
+
// The flash region is the host-app container Response#flash targets. Its id
|
|
1116
|
+
// defaults to "flash" (Phlex::Reactive.flash_target); an app that customized
|
|
1117
|
+
// it can point the fallback at the same node by putting the template's
|
|
1118
|
+
// data-reactive-error-flash value there — but the common case is #flash.
|
|
1119
|
+
const targetId = template.getAttribute("data-reactive-error-flash") || "flash"
|
|
1120
|
+
const region = document.getElementById(targetId)
|
|
1121
|
+
if (!region) return
|
|
1122
|
+
region.appendChild(template.content.cloneNode(true))
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Latency simulator (issue #102): if enableLatencySim(ms) stored a delay in
|
|
1126
|
+
// sessionStorage, await it before the fetch so the busy window (already open
|
|
1127
|
+
// since enqueue) is actually visible on localhost. Reads the key LIVE per
|
|
1128
|
+
// request — like the CSRF token — so toggling the sim mid-session takes effect
|
|
1129
|
+
// on the very next action without a reload. A missing sessionStorage, an absent
|
|
1130
|
+
// key, or a non-positive/NaN value resolves immediately (no timer, no delay) —
|
|
1131
|
+
// the whole feature is inert for any app that never opts in. Warns ONCE while
|
|
1132
|
+
// active (module-level guard), not once per request.
|
|
1133
|
+
#maybeSimulateLatency() {
|
|
1134
|
+
if (typeof sessionStorage === "undefined") return Promise.resolve()
|
|
1135
|
+
const ms = Number(sessionStorage.getItem(LATENCY_KEY))
|
|
1136
|
+
if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve()
|
|
1137
|
+
if (!latencyBannerShown) {
|
|
1138
|
+
latencyBannerShown = true
|
|
1139
|
+
console.warn(
|
|
1140
|
+
`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${ms}ms. ` +
|
|
1141
|
+
"Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.",
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Client debug mode (issue #108) — the "devtools-lite" lens. On when the Ruby
|
|
1148
|
+
// reactive_attrs stamped data-reactive-debug="true" (Phlex::Reactive.debug).
|
|
1149
|
+
// Read live (a single getAttribute) so the whole feature is inert for any app
|
|
1150
|
+
// that never opts in: OFF → this returns false and #perform builds no debug
|
|
1151
|
+
// object, parses no response, and logs nothing (the "zero cost when off"
|
|
1152
|
+
// invariant — one nil-check per dispatch). Guarded for a stub root with no
|
|
1153
|
+
// getAttribute (unit harnesses) so it degrades to off, never throwing.
|
|
1154
|
+
#debugEnabled() {
|
|
1155
|
+
return this.element?.getAttribute?.("data-reactive-debug") === "true"
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// A monotonic timestamp for the round-trip duration (ms). performance.now is
|
|
1159
|
+
// monotonic (immune to a wall-clock adjustment mid-request); Date.now is the
|
|
1160
|
+
// fallback for an exotic environment without it. ONLY called on the debug path,
|
|
1161
|
+
// so it costs nothing when debug is off.
|
|
1162
|
+
#debugNow() {
|
|
1163
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
1164
|
+
? performance.now()
|
|
1165
|
+
: Date.now()
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Parse a turbo-stream response's action + target pairs for the debug trace,
|
|
1169
|
+
// from the body text #perform ALREADY read (never a re-fetch). NAMES only — the
|
|
1170
|
+
// <template> contents (rendered HTML, the fresh token) are deliberately not
|
|
1171
|
+
// touched. A non-turbo-stream / empty body yields [] (nothing to report).
|
|
1172
|
+
#debugStreams(body) {
|
|
1173
|
+
if (!body) return []
|
|
1174
|
+
const streams = []
|
|
1175
|
+
const re = /<turbo-stream\b([^>]*)>/g
|
|
1176
|
+
let match
|
|
1177
|
+
while ((match = re.exec(body)) !== null) {
|
|
1178
|
+
const attrs = match[1]
|
|
1179
|
+
const action = attrs.match(/\baction="([^"]*)"/)?.[1] ?? "?"
|
|
1180
|
+
const target = attrs.match(/\btarget="([^"]*)"/)?.[1]
|
|
1181
|
+
streams.push(target ? `${action} → #${target}` : action)
|
|
1182
|
+
}
|
|
1183
|
+
return streams
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// console.group ONE dispatch (issue #108). Carries NAMES + outcomes ONLY — the
|
|
1187
|
+
// signed token VALUE and every field/param VALUE are deliberately absent (they
|
|
1188
|
+
// may be sensitive; the whole point is observability without leaking data). The
|
|
1189
|
+
// caller passes the info it already holds so nothing is recomputed or re-fetched:
|
|
1190
|
+
// { action, paramNames, fieldNames, encoding, status, streams, tokenRefreshed, ms }
|
|
1191
|
+
// `console.groupCollapsed` keeps the console tidy (one collapsed line per action).
|
|
1192
|
+
#logDispatch(info) {
|
|
1193
|
+
const { action, status, ms } = info
|
|
1194
|
+
// The client can't name the component CLASS (it's inside the signed, opaque
|
|
1195
|
+
// token — never decoded here), but the root's id is the stable client-side
|
|
1196
|
+
// handle (e.g. #todo_42), so the header reads `reactive #todo_42 rename → …`.
|
|
1197
|
+
const who = this.element?.id ? `#${this.element.id} ` : ""
|
|
1198
|
+
const header = `reactive ${who}${action} → ${status ?? "—"} (${Math.round(ms)}ms)`
|
|
1199
|
+
/* eslint-disable no-console */
|
|
1200
|
+
console.groupCollapsed(header)
|
|
1201
|
+
console.log(`params: [${info.paramNames.join(", ")}] + collected: [${info.fieldNames.join(", ")}]`)
|
|
1202
|
+
console.log(`encoding: ${info.encoding}`)
|
|
1203
|
+
if (info.streams.length) console.log(`streams: ${info.streams.join(" ")}`)
|
|
1204
|
+
console.log(`token: ${info.tokenRefreshed ? "refreshed ✓" : "unchanged"}`)
|
|
1205
|
+
console.groupEnd()
|
|
1206
|
+
/* eslint-enable no-console */
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async #perform(action, params, inverse, settle) {
|
|
399
1210
|
// Auto-collect named field values inside this component so a button-
|
|
400
1211
|
// triggered action still receives sibling inputs (Livewire-style), plus any
|
|
401
1212
|
// chosen file inputs in the SAME walk. Explicit params
|
|
402
1213
|
// (data-reactive-params-param) win over collected fields.
|
|
403
1214
|
const { fields, files } = this.#collectFields()
|
|
404
|
-
|
|
1215
|
+
// Parse the explicit trigger params ONCE — reused below for allParams and, on
|
|
1216
|
+
// the debug path, for the params-only name list (so the trace can show
|
|
1217
|
+
// `params: [...]` distinct from the collected `+ collected: [...]`). #parseParams
|
|
1218
|
+
// is pure (a fresh object from a JSON string, or the same object by reference
|
|
1219
|
+
// when already parsed); allParams spreads a COPY and nothing mutates parsedParams
|
|
1220
|
+
// downstream (JSON.stringify / #buildFormData read allParams, a new object).
|
|
1221
|
+
const parsedParams = this.#parseParams(params)
|
|
1222
|
+
const allParams = { ...fields, ...parsedParams }
|
|
405
1223
|
const token = this.#currentToken
|
|
406
1224
|
|
|
407
1225
|
// File/multipart path (issue #34): if THIS root has a populated
|
|
@@ -414,61 +1232,215 @@ export default class extends Controller {
|
|
|
414
1232
|
? this.#buildFormData(token, action, allParams, files)
|
|
415
1233
|
: JSON.stringify({ token, act: action, params: allParams })
|
|
416
1234
|
|
|
417
|
-
|
|
1235
|
+
// Client debug mode (issue #108): build the trace object ONLY when debug is on
|
|
1236
|
+
// (one nil-check otherwise — zero cost off). It carries NAMES only: the
|
|
1237
|
+
// explicit trigger param names and the collected sibling field names, split so
|
|
1238
|
+
// the group shows `params: [...] + collected: [...]`. status/streams/
|
|
1239
|
+
// tokenRefreshed are filled in at the branch that knows them; the group is
|
|
1240
|
+
// emitted once in the finally so EVERY exit (success or any failure) logs.
|
|
1241
|
+
const debug = this.#debugEnabled()
|
|
1242
|
+
? {
|
|
1243
|
+
action,
|
|
1244
|
+
paramNames: Object.keys(parsedParams),
|
|
1245
|
+
fieldNames: Object.keys(fields),
|
|
1246
|
+
encoding: multipart ? "multipart" : "json",
|
|
1247
|
+
status: null,
|
|
1248
|
+
streams: [],
|
|
1249
|
+
tokenRefreshed: false,
|
|
1250
|
+
started: this.#debugNow(),
|
|
1251
|
+
}
|
|
1252
|
+
: null
|
|
1253
|
+
|
|
1254
|
+
// aria-busy on the root is now driven by the loading pending counter
|
|
1255
|
+
// (#applyLoading, applied at ENQUEUE so it covers the queue wait too), not
|
|
1256
|
+
// set here. #settleLoading in the finally clears it — see #enqueue.
|
|
1257
|
+
|
|
1258
|
+
// Latency simulator (issue #102): the busy window (aria-busy + loading state)
|
|
1259
|
+
// is already applied at enqueue, so awaiting the configured delay HERE — after
|
|
1260
|
+
// that window opened and before the fetch — is what makes it visible on
|
|
1261
|
+
// localhost. A null/absent/non-positive sessionStorage key is a no-op (zero
|
|
1262
|
+
// production surface), so this line vanishes for any app that never opts in.
|
|
1263
|
+
await this.#maybeSimulateLatency()
|
|
418
1264
|
|
|
419
1265
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
1266
|
+
// Offline gate (issue #101), authoritative at the NETWORK BOUNDARY (send
|
|
1267
|
+
// time), not at enqueue. A click can enqueue while online and reach here
|
|
1268
|
+
// after going offline (a debounced/queued request, a rapid transition) —
|
|
1269
|
+
// gating in #perform makes the kind consistently "offline" for that whole
|
|
1270
|
+
// condition instead of leaking through as a "network" fetch throw. The
|
|
1271
|
+
// fetch never fires, so the edit is not half-sent; the finally still runs
|
|
1272
|
+
// (settle clears loading), the optimistic hint reverts, and retry() (which
|
|
1273
|
+
// re-enters #perform) re-checks and sends once back online.
|
|
1274
|
+
if (navigator.onLine === false) {
|
|
1275
|
+
this.#revertOptimistic(inverse)
|
|
1276
|
+
this.#markError("offline")
|
|
1277
|
+
this.#emitError(action, params, allParams, { kind: "offline" })
|
|
1278
|
+
return
|
|
423
1279
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1280
|
+
|
|
1281
|
+
let response
|
|
1282
|
+
try {
|
|
1283
|
+
const headers = {
|
|
1284
|
+
Accept: "text/vnd.turbo-stream.html",
|
|
1285
|
+
"X-CSRF-Token": this.#csrfToken(),
|
|
1286
|
+
}
|
|
1287
|
+
// For JSON we declare the content type; for multipart we must NOT — the
|
|
1288
|
+
// browser sets `multipart/form-data; boundary=…` itself, and overriding it
|
|
1289
|
+
// would strip the boundary and corrupt the body server-side.
|
|
1290
|
+
if (!multipart) headers["Content-Type"] = "application/json"
|
|
1291
|
+
// Send the pgbus SSE connection id (if subscribed) so the server can
|
|
1292
|
+
// exclude this connection from its own broadcast echo — the actor
|
|
1293
|
+
// already gets the action's HTTP response. Harmless without pgbus.
|
|
1294
|
+
const connectionId = this.#connectionId()
|
|
1295
|
+
if (connectionId) headers["X-Pgbus-Connection"] = connectionId
|
|
1296
|
+
|
|
1297
|
+
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
1298
|
+
// connection. Everything below this inner try (reading the body,
|
|
1299
|
+
// extracting the token, handing streams to Turbo) runs AFTER the
|
|
1300
|
+
// server already processed the mutation, so none of it belongs in the
|
|
1301
|
+
// `kind: "network"` / retriable bucket (CodeRabbit review on #89): if
|
|
1302
|
+
// renderStreamMessage throws, retry()ing would re-POST an action the
|
|
1303
|
+
// server already completed — see the outer catch below. (NOT a
|
|
1304
|
+
// reactive:applied LISTENER throwing — per the DOM spec, dispatchEvent
|
|
1305
|
+
// never propagates a listener's exception back to its caller, so that
|
|
1306
|
+
// case can't reach this catch at all; verified in the JS test suite.)
|
|
1307
|
+
response = await fetch(this.#actionPath(), {
|
|
1308
|
+
method: "POST",
|
|
1309
|
+
headers,
|
|
1310
|
+
body,
|
|
1311
|
+
credentials: "same-origin",
|
|
1312
|
+
// Bound the request (issue #101): a server that never answers used to
|
|
1313
|
+
// wedge this.queue forever (the finally that clears aria-busy/loading
|
|
1314
|
+
// never ran). AbortSignal.timeout(ms) aborts the fetch after the
|
|
1315
|
+
// configured window; the abort surfaces in this catch as a
|
|
1316
|
+
// DOMException named "TimeoutError" (see the branch below).
|
|
1317
|
+
signal: AbortSignal.timeout(this.#timeoutMs()),
|
|
1318
|
+
})
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
console.error("[phlex-reactive] action error", error)
|
|
1321
|
+
this.#revertOptimistic(inverse)
|
|
1322
|
+
// AbortSignal.timeout() rejects with a DOMException named "TimeoutError"
|
|
1323
|
+
// (a manual AbortController.abort() would be "AbortError" — we don't use
|
|
1324
|
+
// one, but accept it too for robustness). A timeout is NOT "offline":
|
|
1325
|
+
// the request left and the server didn't answer in time; connectivity is
|
|
1326
|
+
// unknown. So do NOT clone the offline-fallback template or mark network
|
|
1327
|
+
// — just fire kind:"timeout" (retriable). The queue still advances
|
|
1328
|
+
// (#perform returns, never rejects), so the hung request un-wedges it.
|
|
1329
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
1330
|
+
this.#markError("timeout")
|
|
1331
|
+
this.#emitError(action, params, allParams, { kind: "timeout" })
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
// No server reached — nothing to render (issue #100). Clone a
|
|
1335
|
+
// server-rendered <template data-reactive-error-flash> into the flash
|
|
1336
|
+
// region as an offline fallback. The template is rendered by the app
|
|
1337
|
+
// (trusted), so this is a pure clone — no client templating of data.
|
|
1338
|
+
this.#renderNetworkFallback()
|
|
1339
|
+
this.#markError("network")
|
|
1340
|
+
this.#emitError(action, params, allParams, { kind: "network" })
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Debug (issue #108): the server answered — record the status for the trace
|
|
1345
|
+
// now, so every response branch below (redirected/http/content-type/ok) logs
|
|
1346
|
+
// it. The transport-failure branches above return before here (they have no
|
|
1347
|
+
// status); their group still fires from the finally with status null → "—".
|
|
1348
|
+
if (debug) debug.status = response.status
|
|
440
1349
|
|
|
441
1350
|
if (response.redirected) {
|
|
442
1351
|
console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied")
|
|
1352
|
+
this.#revertOptimistic(inverse)
|
|
1353
|
+
this.#markError("redirected")
|
|
1354
|
+
this.#emitError(action, params, allParams, { kind: "redirected", status: response.status })
|
|
443
1355
|
return
|
|
444
1356
|
}
|
|
445
1357
|
if (!response.ok) {
|
|
446
|
-
|
|
1358
|
+
const errorBody = await response.text()
|
|
1359
|
+
console.error(`[phlex-reactive] action failed: HTTP ${response.status}`, errorBody)
|
|
1360
|
+
this.#revertOptimistic(inverse)
|
|
1361
|
+
// Render a non-OK turbo-stream body so a server-rendered error flash
|
|
1362
|
+
// (an error_flash rescue, or a status: :unprocessable_entity validation
|
|
1363
|
+
// reply from a plain controller) is actually SHOWN — instead of being
|
|
1364
|
+
// read only for the console (issue #100). #extractToken is run as usual;
|
|
1365
|
+
// it NO-OPS when no stream re-renders our id, so a 400 InvalidToken body
|
|
1366
|
+
// never refreshes the held identity token (which is not a nonce and stays
|
|
1367
|
+
// retry-valid — do not "fix" that). A non-turbo-stream body is left to the
|
|
1368
|
+
// console.error above (an HTML error page must not be handed to Turbo).
|
|
1369
|
+
if ((response.headers.get("Content-Type") || "").includes("turbo-stream")) {
|
|
1370
|
+
const fresh = this.#extractToken(errorBody)
|
|
1371
|
+
this.#currentToken = fresh ?? this.#currentToken
|
|
1372
|
+
if (debug) this.#debugRecordBody(debug, errorBody, fresh)
|
|
1373
|
+
window.Turbo.renderStreamMessage(errorBody)
|
|
1374
|
+
}
|
|
1375
|
+
this.#markError("http")
|
|
1376
|
+
this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
|
|
447
1377
|
return
|
|
448
1378
|
}
|
|
449
1379
|
|
|
450
1380
|
const contentType = response.headers.get("Content-Type") || ""
|
|
451
1381
|
if (!contentType.includes("turbo-stream")) {
|
|
452
1382
|
console.error(`[phlex-reactive] expected a turbo-stream, got "${contentType}" — no update applied`)
|
|
1383
|
+
this.#revertOptimistic(inverse)
|
|
1384
|
+
this.#markError("content-type")
|
|
1385
|
+
this.#emitError(action, params, allParams, { kind: "content-type", status: response.status })
|
|
453
1386
|
return
|
|
454
1387
|
}
|
|
455
1388
|
|
|
456
1389
|
const html = await response.text()
|
|
457
1390
|
// Capture the new token from the response synchronously, so the next
|
|
458
1391
|
// queued request uses it without waiting for the async DOM morph.
|
|
459
|
-
|
|
1392
|
+
const fresh = this.#extractToken(html)
|
|
1393
|
+
this.#currentToken = fresh ?? this.#currentToken
|
|
1394
|
+
// Debug (issue #108): record the stream actions/targets + whether a refresh
|
|
1395
|
+
// arrived, from the body we JUST read (reuse — no second text() read). Never
|
|
1396
|
+
// the token or template contents.
|
|
1397
|
+
if (debug) this.#debugRecordBody(debug, html, fresh)
|
|
460
1398
|
// Turbo applies the <turbo-stream> ops by id. A plain replace is an
|
|
461
1399
|
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
462
1400
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
463
1401
|
// focused input + caret on unchanged nodes — see issue #28.
|
|
464
1402
|
window.Turbo.renderStreamMessage(html)
|
|
1403
|
+
// A successful apply CLEARS any prior failure marker (issue #100), so
|
|
1404
|
+
// error-driven CSS on the root (a red border, a shake) resets on recovery.
|
|
1405
|
+
this.#clearError()
|
|
1406
|
+
// Lifecycle hook (issue #79): the streams were HANDED TO Turbo — a
|
|
1407
|
+
// renderStreamMessage applies asynchronously, so the DOM mutation may
|
|
1408
|
+
// complete a tick later. Apps needing post-morph timing listen to Turbo's
|
|
1409
|
+
// own events; this one is for "the action round trip succeeded".
|
|
1410
|
+
this.#emit("reactive:applied", { action, params: allParams, html })
|
|
465
1411
|
} catch (error) {
|
|
1412
|
+
// The server already processed this action successfully (we're past the
|
|
1413
|
+
// fetch) — a throw here is a CLIENT-side apply failure (a malformed
|
|
1414
|
+
// response, a broken Turbo render — NOT a reactive:applied listener
|
|
1415
|
+
// throw, which dispatchEvent never propagates here), not a transport
|
|
1416
|
+
// failure. kind: "apply" carries NO retry() — retrying would re-POST an
|
|
1417
|
+
// action the server already completed.
|
|
466
1418
|
console.error("[phlex-reactive] action error", error)
|
|
1419
|
+
this.#revertOptimistic(inverse)
|
|
1420
|
+
this.#emit("reactive:error", { action, params: allParams, kind: "apply" })
|
|
467
1421
|
} finally {
|
|
468
|
-
|
|
1422
|
+
// Settle the loading state (issue #99): decrement the pending counter,
|
|
1423
|
+
// drop the trigger's/root's busy tokens, restore disabled/text/class —
|
|
1424
|
+
// guarded so a morph-replaced trigger is never clobbered. Runs on EVERY
|
|
1425
|
+
// exit (success, every failure branch, or an apply throw).
|
|
1426
|
+
settle?.()
|
|
1427
|
+
// Debug (issue #108): emit the group here so EVERY exit path logs exactly
|
|
1428
|
+
// once — success, any transport/response failure, or an apply throw. Null
|
|
1429
|
+
// when debug is off (zero cost). The round-trip ms is measured now, at the
|
|
1430
|
+
// finally, so it spans the whole #perform (fetch + apply) regardless of exit.
|
|
1431
|
+
if (debug) this.#logDispatch({ ...debug, ms: this.#debugNow() - debug.started })
|
|
469
1432
|
}
|
|
470
1433
|
}
|
|
471
1434
|
|
|
1435
|
+
// Debug (issue #108): fold the response body #perform already read into the
|
|
1436
|
+
// trace — the stream action/target pairs and whether a token refresh arrived
|
|
1437
|
+
// (a boolean; the token VALUE is intentionally not stored). Shared by the
|
|
1438
|
+
// success and the non-OK-turbo-stream branches so both log the same shape.
|
|
1439
|
+
#debugRecordBody(debug, body, freshToken) {
|
|
1440
|
+
debug.streams = this.#debugStreams(body)
|
|
1441
|
+
debug.tokenRefreshed = freshToken != null
|
|
1442
|
+
}
|
|
1443
|
+
|
|
472
1444
|
get #currentToken() {
|
|
473
1445
|
return this.#tokenCache ?? this.tokenValue
|
|
474
1446
|
}
|
|
@@ -496,29 +1468,46 @@ export default class extends Controller {
|
|
|
496
1468
|
return html.match(/data-reactive-token-value="([^"]+)"/)?.[1]
|
|
497
1469
|
}
|
|
498
1470
|
|
|
1471
|
+
const { token, self } = this.#tokenRegexes(id)
|
|
1472
|
+
|
|
499
1473
|
// The dedicated token-only refresh for THIS element (partial updates / the
|
|
500
1474
|
// collection container) — an attribute on the <turbo-stream> itself.
|
|
501
|
-
const tokenStream = html.match(
|
|
502
|
-
new RegExp(
|
|
503
|
-
`<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*\\bdata-reactive-token-value="([^"]+)"`,
|
|
504
|
-
),
|
|
505
|
-
)
|
|
1475
|
+
const tokenStream = html.match(token)
|
|
506
1476
|
if (tokenStream) return tokenStream[1]
|
|
507
1477
|
|
|
508
1478
|
// A full self re-render: a replace/update of THIS element whose template root
|
|
509
1479
|
// carries the fresh token. Scope the token search to that one stream so a
|
|
510
1480
|
// sibling/child token elsewhere in the body can't leak in.
|
|
511
|
-
const selfStream = html.match(
|
|
512
|
-
new RegExp(
|
|
513
|
-
`<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*>([\\s\\S]*?)</turbo-stream>`,
|
|
514
|
-
),
|
|
515
|
-
)
|
|
1481
|
+
const selfStream = html.match(self)
|
|
516
1482
|
if (selfStream) return selfStream[1].match(/data-reactive-token-value="([^"]+)"/)?.[1]
|
|
517
1483
|
|
|
518
1484
|
// Nothing re-rendered our id — keep the current token.
|
|
519
1485
|
return undefined
|
|
520
1486
|
}
|
|
521
1487
|
|
|
1488
|
+
// The two per-id RegExps #extractToken uses to self-match this element's next
|
|
1489
|
+
// token (issue #118). `this.element.id` is page-stable, so these are compiled
|
|
1490
|
+
// ONCE and reused across every response — instead of allocating two fresh
|
|
1491
|
+
// RegExps per round trip. The memo is KEYED ON THE ID and rebuilt when it
|
|
1492
|
+
// changes: a re-render that re-identifies the root must scan for the NEW target,
|
|
1493
|
+
// never the stale one (or the token would freeze — see the id-change bun test).
|
|
1494
|
+
// The PATTERNS are byte-identical to the pre-memo inline literals; only their
|
|
1495
|
+
// allocation moved.
|
|
1496
|
+
#tokenRegexes(id) {
|
|
1497
|
+
const cache = this.#tokenRegexCache
|
|
1498
|
+
if (cache && cache.id === id) return cache
|
|
1499
|
+
const escaped = escapeRegExp(id)
|
|
1500
|
+
return (this.#tokenRegexCache = {
|
|
1501
|
+
id,
|
|
1502
|
+
token: new RegExp(
|
|
1503
|
+
`<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${escaped}"[^>]*\\bdata-reactive-token-value="([^"]+)"`,
|
|
1504
|
+
),
|
|
1505
|
+
self: new RegExp(
|
|
1506
|
+
`<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${escaped}"[^>]*>([\\s\\S]*?)</turbo-stream>`,
|
|
1507
|
+
),
|
|
1508
|
+
})
|
|
1509
|
+
}
|
|
1510
|
+
|
|
522
1511
|
// True when `el` is collected by THIS reactive root and not by a nested one.
|
|
523
1512
|
// A reactive component can be rendered inside another (both are
|
|
524
1513
|
// data-controller="reactive" roots). querySelectorAll() descends into nested
|
|
@@ -529,16 +1518,48 @@ export default class extends Controller {
|
|
|
529
1518
|
return el.closest('[data-controller~="reactive"]') === this.element
|
|
530
1519
|
}
|
|
531
1520
|
|
|
1521
|
+
// A per-op ownership PREDICATE — the issue #117 fast path over issue #15
|
|
1522
|
+
// scoping. #ownsField answers "is this element mine, not a nested reactive
|
|
1523
|
+
// root's" with a per-element closest() walk; on a wide form or every keystroke
|
|
1524
|
+
// that walk runs per matched field. This hoists the DECISION to once per op.
|
|
1525
|
+
//
|
|
1526
|
+
// HYBRID GATE — closest() stays the source of truth; the nested-root query only
|
|
1527
|
+
// decides whether the fast path is SAFE:
|
|
1528
|
+
// * Fast path (overwhelmingly common): this root contains NO nested reactive
|
|
1529
|
+
// roots, so every element the caller's querySelectorAll returned is already
|
|
1530
|
+
// a direct descendant of this.element with no intervening reactive root —
|
|
1531
|
+
// it is ours. Return a constant-true predicate and skip the per-field
|
|
1532
|
+
// closest() walk entirely. That is the whole win.
|
|
1533
|
+
// * Nested case: fall back to the UNCHANGED #ownsField closest() check, so
|
|
1534
|
+
// scoping is byte-identical to before. We deliberately do NOT use
|
|
1535
|
+
// contains() here — the closest() form needs no node to implement
|
|
1536
|
+
// contains(), and on a real DOM the two agree for a descendant of
|
|
1537
|
+
// this.element (el.closest('[data-controller~="reactive"]') === this.element
|
|
1538
|
+
// iff no nested reactive-root descendant contains el).
|
|
1539
|
+
//
|
|
1540
|
+
// Computed ONCE per dispatch-scoped op (per #collectFields call, per recompute,
|
|
1541
|
+
// per #listnavOptions) and NEVER stored on the instance — a morph replaces
|
|
1542
|
+
// nodes, so a cached predicate would close over stale roots.
|
|
1543
|
+
#ownershipFilter() {
|
|
1544
|
+
const nested = this.element.querySelectorAll('[data-controller~="reactive"]')
|
|
1545
|
+
if (nested.length === 0) return () => true
|
|
1546
|
+
return (el) => this.#ownsField(el)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
532
1549
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
533
|
-
// returning both the scalar `fields` and any chosen `files`.
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
1550
|
+
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
1551
|
+
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
|
1552
|
+
// no-nested-root case it is a constant true, so we skip a closest() walk per
|
|
1553
|
+
// field. A file input's `.value` is the useless "C:\fakepath\…" string, never a
|
|
1554
|
+
// scalar — so its chosen files are collected separately (honoring `multiple`)
|
|
1555
|
+
// and it adds no phantom blank value (issue #34). An empty `files` keeps the
|
|
1556
|
+
// JSON path.
|
|
537
1557
|
#collectFields() {
|
|
538
1558
|
const fields = {}
|
|
539
1559
|
const files = []
|
|
1560
|
+
const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
|
|
540
1561
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
541
|
-
if (!
|
|
1562
|
+
if (!owns(field)) return
|
|
542
1563
|
if (field.type === "file") {
|
|
543
1564
|
// Carry the input's `multiple` flag so #buildFormData keeps the array
|
|
544
1565
|
// shape (params[name][]) even when the user picked exactly one file —
|
|
@@ -562,7 +1583,7 @@ export default class extends Controller {
|
|
|
562
1583
|
this.element
|
|
563
1584
|
.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
|
|
564
1585
|
.forEach((el) => {
|
|
565
|
-
if (!
|
|
1586
|
+
if (!owns(el)) return // reuse the SAME hoisted predicate (nested reactive root — issue #15)
|
|
566
1587
|
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
567
1588
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
568
1589
|
const name = el.getAttribute("name")
|
|
@@ -575,6 +1596,107 @@ export default class extends Controller {
|
|
|
575
1596
|
return { fields, files }
|
|
576
1597
|
}
|
|
577
1598
|
|
|
1599
|
+
// Re-compute the dirty flag for EVERY field this root owns in one pass (issue
|
|
1600
|
+
// #103), then reflect the total onto the root. Called on an owned field's input
|
|
1601
|
+
// (trackDirty), on connect (baseline seed), and after a turbo:morph-element
|
|
1602
|
+
// re-render (fresh default* attrs). dirty = current ≠ the DOM's own default:
|
|
1603
|
+
// checkbox/radio → checked !== defaultChecked
|
|
1604
|
+
// select → some option.selected !== option.defaultSelected
|
|
1605
|
+
// else → value !== defaultValue
|
|
1606
|
+
// A full pass (not per-target) is REQUIRED: a radio group's previously-checked
|
|
1607
|
+
// radio flips to checked=false with NO input event, so per-target toggling
|
|
1608
|
+
// would leave its flag stale. File inputs are skipped — a file has no server
|
|
1609
|
+
// default baseline. Per-dirty-field data-reactive-dirty="true" ("true" STRING,
|
|
1610
|
+
// not a valueless boolean attr — mirrors the on() flag convention); the root
|
|
1611
|
+
// carries data-reactive-dirty="<count>" and DROPS the attr at zero, so
|
|
1612
|
+
// `[data-reactive-dirty]` styles the whole form and `[data-reactive-dirty]`
|
|
1613
|
+
// on a field styles just the changed control — both pure CSS, zero JS.
|
|
1614
|
+
#scanDirty() {
|
|
1615
|
+
// Runs at bootstrap (the connect baseline seed) as well as on input/morph, so
|
|
1616
|
+
// it must never throw — degrade to a no-op if the root can't be queried (a real
|
|
1617
|
+
// reactive root always can; this guards minimal/test element stubs).
|
|
1618
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
1619
|
+
|
|
1620
|
+
let count = 0
|
|
1621
|
+
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
1622
|
+
if (!this.#ownsField(field)) return // skip a nested reactive root's fields (issue #15)
|
|
1623
|
+
if (field.type === "file") return // no server default baseline to diff against
|
|
1624
|
+
|
|
1625
|
+
if (this.#fieldDirty(field)) {
|
|
1626
|
+
field.setAttribute("data-reactive-dirty", "true")
|
|
1627
|
+
count++
|
|
1628
|
+
} else {
|
|
1629
|
+
field.removeAttribute("data-reactive-dirty")
|
|
1630
|
+
}
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
if (count > 0) this.element.setAttribute("data-reactive-dirty", String(count))
|
|
1634
|
+
else this.element.removeAttribute("data-reactive-dirty")
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Whether a single owned control differs from its server-rendered default.
|
|
1638
|
+
#fieldDirty(field) {
|
|
1639
|
+
if (field.type === "checkbox" || field.type === "radio") {
|
|
1640
|
+
return field.checked !== field.defaultChecked
|
|
1641
|
+
}
|
|
1642
|
+
if (field.tag === "select" || field.options) {
|
|
1643
|
+
// Any option whose selected state diverges from its defaultSelected. Guard
|
|
1644
|
+
// for a stub/absent options list (degrade to clean).
|
|
1645
|
+
return Array.from(field.options ?? []).some((o) => o.selected !== o.defaultSelected)
|
|
1646
|
+
}
|
|
1647
|
+
return field.value !== field.defaultValue
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// The live dirty-field count, re-derived from the DOM (never a cached snapshot)
|
|
1651
|
+
// — the source of truth for the warn_unsaved guard's gate.
|
|
1652
|
+
#dirtyCount() {
|
|
1653
|
+
const raw = this.element.getAttribute?.("data-reactive-dirty")
|
|
1654
|
+
const n = Number(raw)
|
|
1655
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Arm the navigate-away guard (warn_unsaved: true, issue #103). beforeunload
|
|
1659
|
+
// blocks a real browser unload; turbo:before-visit blocks a Turbo in-app
|
|
1660
|
+
// navigation (it does NOT fire on restoration visits — the documented gap).
|
|
1661
|
+
// Both read the LIVE dirty count, so a clean form never blocks. Handlers are
|
|
1662
|
+
// stored so disconnect() removes exactly them.
|
|
1663
|
+
#armUnsavedGuard() {
|
|
1664
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return
|
|
1665
|
+
|
|
1666
|
+
this.#boundBeforeUnload = (event) => {
|
|
1667
|
+
if (this.#dirtyCount() === 0) return undefined
|
|
1668
|
+
// The spec dance: preventDefault + a truthy returnValue triggers the native
|
|
1669
|
+
// "leave site?" prompt. The string is legacy (modern browsers show their own
|
|
1670
|
+
// copy) but must be non-empty/truthy to arm the dialog.
|
|
1671
|
+
event.preventDefault()
|
|
1672
|
+
event.returnValue = "You have unsaved changes."
|
|
1673
|
+
return event.returnValue
|
|
1674
|
+
}
|
|
1675
|
+
this.#boundBeforeVisit = (event) => {
|
|
1676
|
+
if (this.#dirtyCount() === 0) return
|
|
1677
|
+
const ok = typeof window.confirm === "function" ? window.confirm("You have unsaved changes. Leave anyway?") : true
|
|
1678
|
+
if (!ok) event.preventDefault?.()
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
window.addEventListener("beforeunload", this.#boundBeforeUnload)
|
|
1682
|
+
window.addEventListener("turbo:before-visit", this.#boundBeforeVisit)
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Remove the dirty-tracking listeners on disconnect (Turbo morph/navigation) so
|
|
1686
|
+
// a morph re-scan or a navigate-away guard never runs against a detached root.
|
|
1687
|
+
#teardownDirtyTracking() {
|
|
1688
|
+
if (this.#boundScanDirty) {
|
|
1689
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundScanDirty)
|
|
1690
|
+
this.#boundScanDirty = undefined
|
|
1691
|
+
}
|
|
1692
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
1693
|
+
if (this.#boundBeforeUnload) window.removeEventListener("beforeunload", this.#boundBeforeUnload)
|
|
1694
|
+
if (this.#boundBeforeVisit) window.removeEventListener("turbo:before-visit", this.#boundBeforeVisit)
|
|
1695
|
+
}
|
|
1696
|
+
this.#boundBeforeUnload = undefined
|
|
1697
|
+
this.#boundBeforeVisit = undefined
|
|
1698
|
+
}
|
|
1699
|
+
|
|
578
1700
|
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
579
1701
|
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
580
1702
|
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|
|
@@ -648,6 +1770,278 @@ export default class extends Controller {
|
|
|
648
1770
|
}
|
|
649
1771
|
}
|
|
650
1772
|
|
|
1773
|
+
// Stimulus typecasts a JSON param to the parsed array already; a raw string
|
|
1774
|
+
// (hand-built attr, non-typecasting harness) is parsed here. Delegates to the
|
|
1775
|
+
// shared parseOps so the controller and the reactive:js stream action (issue
|
|
1776
|
+
// #97) treat a malformed ops attr identically (→ [], never a throw).
|
|
1777
|
+
#parseOps(raw) {
|
|
1778
|
+
return parseOps(raw)
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Interpret a [[name, args], ...] op list against this root (issue #95),
|
|
1782
|
+
// scoping each op's targets to this controller's own root via #opTargets (the
|
|
1783
|
+
// nested-reactive-root ownership filter, issue #15). The whitelist + skip
|
|
1784
|
+
// logic lives in the shared applyOps so runOps and the reactive:js stream
|
|
1785
|
+
// action interpret the SAME vocabulary the SAME way (client-side default-deny).
|
|
1786
|
+
#applyOps(list) {
|
|
1787
|
+
applyOps(list, (args) => this.#opTargets(args))
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Resolve an op's targets: "@root" is this element; a selector resolves
|
|
1791
|
+
// WITHIN this root and excludes nested reactive roots' subtrees (issue #15
|
|
1792
|
+
// semantics — the same nearest-root ownership check the field walk uses);
|
|
1793
|
+
// global: true opts a single op out to document-wide.
|
|
1794
|
+
#opTargets(args) {
|
|
1795
|
+
const to = args.to
|
|
1796
|
+
if (to === "@root") return [this.element]
|
|
1797
|
+
if (typeof to !== "string" || to === "") return []
|
|
1798
|
+
if (args.global) return [...document.querySelectorAll(to)]
|
|
1799
|
+
return [...this.element.querySelectorAll(to)].filter((el) => this.#ownsField(el))
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Whether a click-bound checkbox/radio trigger should keep its NATIVE flip
|
|
1803
|
+
// (issue #98). `checked: :keep` means "let the control flip now": the
|
|
1804
|
+
// unconditional preventDefault is exactly what suppresses that flip until the
|
|
1805
|
+
// morph, so we skip it — but ONLY for a checkbox/radio (a bare toggle click
|
|
1806
|
+
// has no form-navigation default to lose). Any other element (a button) keeps
|
|
1807
|
+
// preventDefault so an in-form submit can't navigate.
|
|
1808
|
+
#keepsNativeToggle(optimistic, target) {
|
|
1809
|
+
if (optimistic?.checked !== "keep") return false
|
|
1810
|
+
const type = target?.type
|
|
1811
|
+
return type === "checkbox" || type === "radio"
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Apply the optimistic hint (issue #98) to its targets NOW and return the
|
|
1815
|
+
// INVERSE — the exact ops to replay on failure. Cosmetic only: class ops and
|
|
1816
|
+
// hidden, applied to the trigger by default or to a `to:` selector scoped to
|
|
1817
|
+
// the root. `checked: :keep` records the trigger's post-flip state so a
|
|
1818
|
+
// failure snaps the native control back; it applies no DOM change itself (the
|
|
1819
|
+
// browser already flipped it). Returns null when there is nothing to do, so
|
|
1820
|
+
// the success/failure paths can cheaply skip.
|
|
1821
|
+
#applyOptimistic(optimistic, trigger) {
|
|
1822
|
+
if (!optimistic) return null
|
|
1823
|
+
|
|
1824
|
+
// Class + hidden ops share one target set (trigger, or the `to:` selector).
|
|
1825
|
+
const targets = this.#optimisticTargets(optimistic, trigger)
|
|
1826
|
+
const undo = []
|
|
1827
|
+
for (const el of targets) {
|
|
1828
|
+
if (optimistic.add_class) {
|
|
1829
|
+
// Undo only the classes this op ACTUALLY added — a class already present
|
|
1830
|
+
// was not our change, so reverting it would strip a class the element
|
|
1831
|
+
// legitimately had (the add was a no-op). Capture the real delta now.
|
|
1832
|
+
const added = optimistic.add_class.filter((c) => !el.classList.contains(c))
|
|
1833
|
+
el.classList.add(...added)
|
|
1834
|
+
if (added.length) undo.push(() => el.classList.remove(...added))
|
|
1835
|
+
}
|
|
1836
|
+
if (optimistic.remove_class) {
|
|
1837
|
+
// Symmetric: undo only the classes actually removed — one already absent
|
|
1838
|
+
// wasn't our change, so re-adding it would introduce a class that wasn't
|
|
1839
|
+
// there before.
|
|
1840
|
+
const removed = optimistic.remove_class.filter((c) => el.classList.contains(c))
|
|
1841
|
+
el.classList.remove(...removed)
|
|
1842
|
+
if (removed.length) undo.push(() => el.classList.add(...removed))
|
|
1843
|
+
}
|
|
1844
|
+
if (optimistic.toggle_class) {
|
|
1845
|
+
// toggle_class is its own inverse regardless of prior state — toggling
|
|
1846
|
+
// the same classes back exactly restores it, no delta tracking needed.
|
|
1847
|
+
optimistic.toggle_class.forEach((c) => el.classList.toggle(c))
|
|
1848
|
+
undo.push(() => optimistic.toggle_class.forEach((c) => el.classList.toggle(c)))
|
|
1849
|
+
}
|
|
1850
|
+
if (optimistic.hide) {
|
|
1851
|
+
el.hidden = true
|
|
1852
|
+
undo.push(() => (el.hidden = false))
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// checked: :keep — the native flip already happened on the trigger; record
|
|
1857
|
+
// the inverse (flip it back) so a failure reverts the control's state.
|
|
1858
|
+
if (optimistic.checked === "keep" && trigger && "checked" in trigger) {
|
|
1859
|
+
const flipped = trigger.checked
|
|
1860
|
+
undo.push(() => (trigger.checked = !flipped))
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
return undo.length ? undo : null
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Replay the recorded inverse ops on failure (issue #98), guarded by
|
|
1867
|
+
// isConnected: a plain (non-morph) replace can detach this subtree before the
|
|
1868
|
+
// failure lands, and reverting a stale/detached node is pointless (it's gone)
|
|
1869
|
+
// — so a disconnected root skips the revert entirely. On success NOTHING calls
|
|
1870
|
+
// this: the server re-render overwrites the hint, or (reply.remove /
|
|
1871
|
+
// streams-only) the hint is deliberately left standing.
|
|
1872
|
+
#revertOptimistic(inverse) {
|
|
1873
|
+
if (!inverse) return
|
|
1874
|
+
if (!this.element.isConnected) return
|
|
1875
|
+
for (const undo of inverse) undo()
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// The elements an optimistic class/hidden hint applies to: the `to:` selector
|
|
1879
|
+
// (resolved like an op target — "@root" is the root, a selector is scoped to
|
|
1880
|
+
// this root's owned matches) or, with no `to:`, the trigger itself.
|
|
1881
|
+
#optimisticTargets(optimistic, trigger) {
|
|
1882
|
+
if (optimistic.to == null) return trigger ? [trigger] : []
|
|
1883
|
+
return this.#opTargets({ to: optimistic.to })
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Apply the loading state for THIS enqueue (issue #99) and return a `settle`
|
|
1887
|
+
// closure that undoes exactly this enqueue's contribution when the round trip
|
|
1888
|
+
// finishes (success OR any failure). Everything is refcounted so overlapping
|
|
1889
|
+
// enqueues never clobber: A's settle can't clear busy while B is still pending.
|
|
1890
|
+
//
|
|
1891
|
+
// Two layers:
|
|
1892
|
+
// 1. The ALWAYS-ON busy vocabulary (fires for every action, no hint needed):
|
|
1893
|
+
// data-reactive-busy="<action>" on the trigger and the root (a
|
|
1894
|
+
// space-separated, per-action refcounted set), aria-busy on the root (a
|
|
1895
|
+
// pending counter), and data-reactive-busy on any busy_on element scoped
|
|
1896
|
+
// to this action. Apps style a spinner with pure CSS and zero Ruby.
|
|
1897
|
+
// 2. The loading HINT (only when loading:/disable_with: was declared):
|
|
1898
|
+
// disable the trigger, add a loading class (to the trigger or a `to:`
|
|
1899
|
+
// target), swap its text. These apply at ENQUEUE — never during a debounce
|
|
1900
|
+
// quiet period — so a debounced input is not disabled mid-typing.
|
|
1901
|
+
#applyLoading(action, trigger, loading) {
|
|
1902
|
+
this.#markBusy(action, trigger)
|
|
1903
|
+
const restoreHint = this.#applyLoadingHint(action, trigger, loading)
|
|
1904
|
+
|
|
1905
|
+
let settled = false
|
|
1906
|
+
return () => {
|
|
1907
|
+
if (settled) return // one settle per enqueue, even if called twice
|
|
1908
|
+
settled = true
|
|
1909
|
+
this.#unmarkBusy(action, trigger)
|
|
1910
|
+
restoreHint()
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Layer 1 — the always-on busy markers. Trigger + root carry the action token;
|
|
1915
|
+
// the root's counter drives aria-busy; busy_on elements scoped to this action
|
|
1916
|
+
// light up. Refcounts (#busyActions, #busyPending) so overlapping requests
|
|
1917
|
+
// don't clear each other.
|
|
1918
|
+
#markBusy(action, trigger) {
|
|
1919
|
+
this.#setBusyToken(trigger, action, +1)
|
|
1920
|
+
this.#setBusyToken(this.element, action, +1)
|
|
1921
|
+
|
|
1922
|
+
this.#busyActions.set(action, (this.#busyActions.get(action) ?? 0) + 1)
|
|
1923
|
+
if (this.#busyPending++ === 0) this.element.setAttribute("aria-busy", "true")
|
|
1924
|
+
|
|
1925
|
+
for (const el of this.#busyOnTargets(action)) this.#setBusyToken(el, action, +1)
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
#unmarkBusy(action, trigger) {
|
|
1929
|
+
this.#setBusyToken(trigger, action, -1)
|
|
1930
|
+
this.#setBusyToken(this.element, action, -1)
|
|
1931
|
+
|
|
1932
|
+
const count = (this.#busyActions.get(action) ?? 1) - 1
|
|
1933
|
+
if (count <= 0) this.#busyActions.delete(action)
|
|
1934
|
+
else this.#busyActions.set(action, count)
|
|
1935
|
+
|
|
1936
|
+
if (--this.#busyPending <= 0) {
|
|
1937
|
+
this.#busyPending = 0
|
|
1938
|
+
this.element.removeAttribute("aria-busy")
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
for (const el of this.#busyOnTargets(action)) this.#setBusyToken(el, action, -1)
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Add (+1) or remove (-1) `action` from an element's space-separated
|
|
1945
|
+
// data-reactive-busy token set, refcounted PER ELEMENT+ACTION so two queued
|
|
1946
|
+
// requests of the same action on the same element don't drop the token early
|
|
1947
|
+
// (and two DIFFERENT actions both keep their token — the set never clobbers).
|
|
1948
|
+
// The attribute is removed only when the set empties. No-op on a nullish/
|
|
1949
|
+
// detached element (a morph may have replaced the trigger before settle).
|
|
1950
|
+
#setBusyToken(el, action, delta) {
|
|
1951
|
+
if (!el || typeof el.getAttribute !== "function") return
|
|
1952
|
+
|
|
1953
|
+
const counts = (this.#busyTokenCounts.get(el) ?? new Map())
|
|
1954
|
+
const next = (counts.get(action) ?? 0) + delta
|
|
1955
|
+
if (next <= 0) counts.delete(action)
|
|
1956
|
+
else counts.set(action, next)
|
|
1957
|
+
|
|
1958
|
+
if (counts.size === 0) {
|
|
1959
|
+
this.#busyTokenCounts.delete(el)
|
|
1960
|
+
el.removeAttribute("data-reactive-busy")
|
|
1961
|
+
return
|
|
1962
|
+
}
|
|
1963
|
+
this.#busyTokenCounts.set(el, counts)
|
|
1964
|
+
el.setAttribute("data-reactive-busy", [...counts.keys()].join(" "))
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// busy_on elements scoped to THIS action, owned by this root (not a nested
|
|
1968
|
+
// reactive root's, issue #15). data-reactive-busy-on="<action>" is the marker
|
|
1969
|
+
// busy_on(:action) emits.
|
|
1970
|
+
#busyOnTargets(action) {
|
|
1971
|
+
const nodes = this.element.querySelectorAll?.("[data-reactive-busy-on]") ?? []
|
|
1972
|
+
return [...nodes].filter(
|
|
1973
|
+
(el) => el.getAttribute("data-reactive-busy-on") === action && this.#ownsField(el),
|
|
1974
|
+
)
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Layer 2 — the loading HINT (disable + class + text). Snapshots the trigger's
|
|
1978
|
+
// ORIGINAL disabled/text/classes on the FIRST enqueue for that trigger
|
|
1979
|
+
// (refcounted so an overlapping enqueue never snapshots the already-swapped
|
|
1980
|
+
// "Saving…" as the original), applies the swap, and returns a restore closure.
|
|
1981
|
+
// With no hint, returns a no-op restore (the always-on busy markers still ran).
|
|
1982
|
+
#applyLoadingHint(action, trigger, loading) {
|
|
1983
|
+
if (!loading || !trigger) return () => {}
|
|
1984
|
+
|
|
1985
|
+
const classTargets = this.#loadingTargets(loading, trigger)
|
|
1986
|
+
const classes = Array.isArray(loading.class) ? loading.class : []
|
|
1987
|
+
const addedByTarget = []
|
|
1988
|
+
for (const el of classTargets) {
|
|
1989
|
+
const added = classes.filter((c) => !el.classList.contains(c))
|
|
1990
|
+
el.classList.add(...added)
|
|
1991
|
+
if (added.length) addedByTarget.push([el, added])
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// Snapshot disabled/text ONCE per trigger (refcounted). A second overlapping
|
|
1995
|
+
// enqueue increments the count but does NOT re-snapshot — so the recorded
|
|
1996
|
+
// "original" is the true pre-loading state, never the swapped label.
|
|
1997
|
+
const snap = this.#loadingSnapshots.get(trigger)
|
|
1998
|
+
if (snap) {
|
|
1999
|
+
snap.count++
|
|
2000
|
+
} else if (loading.disable || loading.text != null) {
|
|
2001
|
+
this.#loadingSnapshots.set(trigger, {
|
|
2002
|
+
count: 1,
|
|
2003
|
+
disabled: trigger.disabled,
|
|
2004
|
+
text: trigger.textContent,
|
|
2005
|
+
hadText: loading.text != null,
|
|
2006
|
+
})
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (loading.disable) trigger.disabled = true
|
|
2010
|
+
if (loading.text != null) trigger.textContent = loading.text
|
|
2011
|
+
|
|
2012
|
+
return () => {
|
|
2013
|
+
for (const [el, added] of addedByTarget) if (el.isConnected) el.classList.remove(...added)
|
|
2014
|
+
this.#restoreLoadingSnapshot(trigger, loading)
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Restore the trigger's disabled/text from its snapshot when the LAST enqueue
|
|
2019
|
+
// for that trigger settles (refcount → 0). GUARDED: skip a disconnected
|
|
2020
|
+
// trigger (a plain replace detached it — the node is gone), and do NOT restore
|
|
2021
|
+
// the text if it no longer equals what we swapped IN (a morph rendered a new
|
|
2022
|
+
// server label — clobbering it with the old text would fight server truth).
|
|
2023
|
+
#restoreLoadingSnapshot(trigger, loading) {
|
|
2024
|
+
const snap = this.#loadingSnapshots.get(trigger)
|
|
2025
|
+
if (!snap) return
|
|
2026
|
+
if (--snap.count > 0) return // another enqueue for this trigger is still pending
|
|
2027
|
+
this.#loadingSnapshots.delete(trigger)
|
|
2028
|
+
|
|
2029
|
+
if (!trigger.isConnected) return // detached — nothing to restore
|
|
2030
|
+
|
|
2031
|
+
if (loading.disable) trigger.disabled = snap.disabled
|
|
2032
|
+
// Only restore the label if the trigger still shows OUR swapped text; a
|
|
2033
|
+
// changed textContent means the server morph relabeled it — leave it.
|
|
2034
|
+
if (snap.hadText && trigger.textContent === loading.text) trigger.textContent = snap.text
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// The elements a loading class applies to: the `to:` selector (resolved like
|
|
2038
|
+
// an op target — "@root" is the root, a selector is scoped to this root's
|
|
2039
|
+
// owned matches) or, with no `to:`, the trigger itself.
|
|
2040
|
+
#loadingTargets(loading, trigger) {
|
|
2041
|
+
if (loading.to == null) return trigger ? [trigger] : []
|
|
2042
|
+
return this.#opTargets({ to: loading.to })
|
|
2043
|
+
}
|
|
2044
|
+
|
|
651
2045
|
// The action path comes from a <meta> tag that is fixed for the page's life,
|
|
652
2046
|
// so resolve it once per controller and cache it — avoids a querySelector on
|
|
653
2047
|
// every dispatch (this runs on the request hot path, once per click/keystroke
|
|
@@ -659,6 +2053,19 @@ export default class extends Controller {
|
|
|
659
2053
|
"/reactive/actions")
|
|
660
2054
|
}
|
|
661
2055
|
|
|
2056
|
+
// The per-request timeout in ms (issue #101), from a page-stable
|
|
2057
|
+
// <meta name="phlex-reactive-timeout"> (default 30000). Cached per-controller
|
|
2058
|
+
// like the action path. Parsed defensively: a missing/blank/non-positive/NaN
|
|
2059
|
+
// value falls back to the default, so a typo'd meta can never disable the
|
|
2060
|
+
// timeout (which would reintroduce the wedged-queue bug) or set a zero/negative
|
|
2061
|
+
// window that aborts instantly.
|
|
2062
|
+
#timeoutMs() {
|
|
2063
|
+
if (this.#timeoutMsCache != null) return this.#timeoutMsCache
|
|
2064
|
+
const raw = document.querySelector('meta[name="phlex-reactive-timeout"]')?.content
|
|
2065
|
+
const ms = Number(raw)
|
|
2066
|
+
return (this.#timeoutMsCache = Number.isFinite(ms) && ms > 0 ? ms : 30000)
|
|
2067
|
+
}
|
|
2068
|
+
|
|
662
2069
|
// CSRF token and connection id are read LIVE (not cached) on purpose: Rails
|
|
663
2070
|
// can rotate the CSRF token, and the pgbus connection id changes on an SSE
|
|
664
2071
|
// reconnect — caching either would send a stale value. A single querySelector
|