phlex-reactive 0.4.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -3
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. 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
- const { action, params, debounce, confirm } = event.params
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
- event.preventDefault()
198
-
199
- // Capture the trigger element now; #proceed runs in a later microtask (after
200
- // the confirm resolver settles), by which point event.target may be reset.
201
- const target = event.target
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
- recompute() {
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 inputs) values[name] = this.#numericFieldValue(name)
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
- const result = reduce(values) || {}
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 = this.#ownedField(name)
251
- // Setting .value fires the field's own `input` listeners (a chained summary
252
- // repaint), matching the server's set_value + dispatch("input") contract.
253
- if (field) field.value = result[name]
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 nodes = this.element.querySelectorAll(selector)
317
- return Array.from(nodes).filter((el) => this.#ownsField(el))
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
- // The first named control owned by THIS root (skips nested reactive roots,
334
- // issue #15) used by recompute to read inputs and write outputs.
335
- #ownedField(name) {
336
- const nodes = this.element.querySelectorAll(`[name="${name}"]`)
337
- for (const el of nodes) if (this.#ownsField(el)) return el
338
- return null
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
- // A field's value as a Number, treating blank/absent/NaN as 0 mirroring the
342
- // nanToZero coercion the hand-written calculators use, so a reducer never sees
343
- // "" or NaN for an empty field.
344
- #numericFieldValue(name) {
345
- const field = this.#ownedField(name)
346
- const n = Number(field?.value)
347
- return Number.isFinite(n) ? n : 0
910
+ // The declared compute input the event just editedthe 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
- return this.#enqueue(action, params)
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
- #enqueue(action, params) {
364
- this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params))
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
- async #perform(action, params) {
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
- const allParams = { ...fields, ...this.#parseParams(params) }
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
- this.element.setAttribute("aria-busy", "true")
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
- const headers = {
421
- Accept: "text/vnd.turbo-stream.html",
422
- "X-CSRF-Token": this.#csrfToken(),
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
- // For JSON we declare the content type; for multipart we must NOT — the
425
- // browser sets `multipart/form-data; boundary=…` itself, and overriding it
426
- // would strip the boundary and corrupt the body server-side.
427
- if (!multipart) headers["Content-Type"] = "application/json"
428
- // Send the pgbus SSE connection id (if subscribed) so the server can
429
- // exclude this connection from its own broadcast echo — the actor
430
- // already gets the action's HTTP response. Harmless without pgbus.
431
- const connectionId = this.#connectionId()
432
- if (connectionId) headers["X-Pgbus-Connection"] = connectionId
433
-
434
- const response = await fetch(this.#actionPath(), {
435
- method: "POST",
436
- headers,
437
- body,
438
- credentials: "same-origin",
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
- console.error(`[phlex-reactive] action failed: HTTP ${response.status}`, await response.text())
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
- this.#currentToken = this.#extractToken(html) ?? this.#currentToken
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
- this.element.removeAttribute("aria-busy")
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`. A file input's
534
- // `.value` is the useless "C:\fakepath\…" string, never a scalar so its
535
- // chosen files are collected separately (honoring `multiple`) and it adds no
536
- // phantom blank value (issue #34). An empty `files` keeps the JSON path.
1550
+ // returning both the scalar `fields` and any chosen `files`. The ownership
1551
+ // predicate is hoisted ONCE (issue #117) via #ownershipFilterin 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 (!this.#ownsField(field)) return
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 (!this.#ownsField(el)) return // skip editors owned by a nested reactive root (issue #15)
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