phlex-reactive 0.10.0 → 0.11.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.
@@ -13,6 +13,9 @@ import { confirmResolver } from "phlex/reactive/confirm"
13
13
  // Client-side computes (data bindings): the reducer registry behind
14
14
  // reactive_compute. Bare specifier for the same import-map reason as confirm.
15
15
  import { computeReducer } from "phlex/reactive/compute"
16
+ // Conditional-confirm predicates (issue #179): the registry behind the
17
+ // confirm: { predicate: "name" } escape hatch. Same bare-specifier reason.
18
+ import { confirmPredicate } from "phlex/reactive/confirm_predicate"
16
19
 
17
20
  // The ONE generic controller behind every reactive Phlex component. It
18
21
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
@@ -1011,7 +1014,12 @@ export default class extends Controller {
1011
1014
  #busyPending = 0 // root aria-busy pending counter (remove only at zero)
1012
1015
  #busyActions = new Map() // action -> in-flight count (root's space-separated busy set + busy_on)
1013
1016
  #busyTokenCounts = new WeakMap() // element -> Map(action -> count): its data-reactive-busy token set
1014
- #loadingSnapshots = new Map() // trigger element -> { count, disabled, text } refcounted snapshot
1017
+ #textDisableSnapshots = new Map() // trigger -> { count, disabled, html } refcounted text/disable snapshot (issue #181)
1018
+ // Issue #183: the `input` events recompute dispatches for its OWN output writes,
1019
+ // marked so a re-entrant recompute on THIS root skips re-running the reducer
1020
+ // (single-pass write set). Per-instance, so another root's events are never
1021
+ // swallowed. WeakSet: entries drop when the short-lived Event is GC'd.
1022
+ #computeSelfDispatched = new WeakSet()
1015
1023
  // Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the
1016
1024
  // navigate-away guard handlers, held so disconnect() can remove exactly them.
1017
1025
  #boundScanDirty
@@ -1186,10 +1194,17 @@ export default class extends Controller {
1186
1194
  // modifier params (issue #80). The client decides preventDefault behavior
1187
1195
  // from event.params — set by the Ruby on() — never by sniffing the
1188
1196
  // Stimulus descriptor.
1189
- const { action, params, debounce, throttle, confirm, outside, window: windowBound, optimistic, loading } =
1197
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
1190
1198
  event.params
1191
1199
  if (!action) return
1192
1200
 
1201
+ // The pending-state hint (issue #181): data-reactive-busy-param. During a
1202
+ // deploy overlap a page rendered by the PREVIOUS gem still emits the old
1203
+ // data-reactive-loading-param — read it as a fallback so an in-flight page
1204
+ // keeps its pending affordance until the next full render. The old `class:`
1205
+ // key is remapped to add_class: so it flows through the one hint applier.
1206
+ const busy = event.params.busy ?? this.#legacyLoadingHint(event.params.loading)
1207
+
1193
1208
  // Outside guard FIRST (issue #80): an outside: trigger only fires for
1194
1209
  // events whose target is OUTSIDE this component's ROOT (containment against
1195
1210
  // this.element — .contains includes the root itself). An event inside the
@@ -1230,8 +1245,14 @@ export default class extends Controller {
1230
1245
  // `change` isn't cancelable, so preventDefault was already a no-op there.
1231
1246
  if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
1232
1247
 
1233
- // No confirm message proceed straight away (unchanged fast path).
1234
- if (!confirm) return this.#proceed(target, action, params, debounce, throttle, optimistic, loading)
1248
+ // Resolve the EFFECTIVE confirm message (issue #179): a plain string confirm:
1249
+ // is that string (static, #52); a Hash confirm: (confirmWhen) evaluates its
1250
+ // condition/predicate over the collected fields and returns the message ONLY
1251
+ // when it fires, else null → no dialog. No confirm at all → also null.
1252
+ const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
1253
+
1254
+ // No message → proceed straight away (unchanged fast path).
1255
+ if (!message) return this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
1235
1256
 
1236
1257
  // Confirmation gate (issue #52, made overridable + async in #55). A reactive
1237
1258
  // trigger can't use Hotwire's data-turbo-confirm — this controller preempts
@@ -1245,10 +1266,10 @@ export default class extends Controller {
1245
1266
  // genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
1246
1267
  // truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
1247
1268
  Promise.resolve()
1248
- .then(() => confirmResolver(confirm))
1269
+ .then(() => confirmResolver(message))
1249
1270
  .catch(() => false)
1250
1271
  .then((ok) => {
1251
- if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, loading)
1272
+ if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
1252
1273
  })
1253
1274
  }
1254
1275
 
@@ -1259,7 +1280,7 @@ export default class extends Controller {
1259
1280
  // the component resets whatever they toggled (by design — a signed action
1260
1281
  // owns state that must survive re-renders).
1261
1282
  runOps(event) {
1262
- const { ops, outside, window: windowBound } = event.params
1283
+ const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
1263
1284
 
1264
1285
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
1265
1286
  // outside: trigger is a COMPLETE no-op for events inside this root, before
@@ -1269,10 +1290,33 @@ export default class extends Controller {
1269
1290
  // Element-bound triggers preventDefault (a bare button inside a <form>
1270
1291
  // must not submit it); window-bound triggers (window:/outside:) never do —
1271
1292
  // they hear every matching event on the page, and preventDefault-ing those
1272
- // would kill native clicks site-wide (issue #80 rationale).
1293
+ // would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
1294
+ // (possibly async) confirm gate below — a native default can't wait for a
1295
+ // pending dialog (same ordering as dispatch()).
1273
1296
  if (!windowBound) event.preventDefault()
1274
1297
 
1275
- this.#applyOps(this.#parseOps(ops))
1298
+ // Resolve the effective confirm message — static string, or the conditional
1299
+ // Hash form (issue #179) evaluated over collected fields. Null → no dialog.
1300
+ const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
1301
+
1302
+ // No message → apply straight away (unchanged fast path, no prompt).
1303
+ if (!message) return this.#applyOps(this.#parseOps(ops))
1304
+
1305
+ // Confirmation gate for client ops (issue #178) — the SAME confirmResolver
1306
+ // gate on(:action, confirm:) uses (issues #52/#55), reused verbatim so a
1307
+ // themed dialog set with setConfirmResolver covers BOTH paths. A destructive
1308
+ // client op (clear a draft, reset a form) gets the one-line themed confirm
1309
+ // without a round trip. Call the resolver INSIDE the chain (leading .then)
1310
+ // so even a SYNCHRONOUS override throw rejects here instead of escaping
1311
+ // runOps — a throwing dialog is a cancel, like dismissing it. The gate is
1312
+ // here (the user gesture), NOT in #applyOps: that applier is shared with the
1313
+ // server-pushed reactive:js stream action, which must NEVER prompt.
1314
+ Promise.resolve()
1315
+ .then(() => confirmResolver(message))
1316
+ .catch(() => false)
1317
+ .then((ok) => {
1318
+ if (ok) this.#applyOps(this.#parseOps(ops))
1319
+ })
1276
1320
  }
1277
1321
 
1278
1322
  // Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
@@ -1305,6 +1349,16 @@ export default class extends Controller {
1305
1349
  // changed = that output's name — the reducer must be convergent (see
1306
1350
  // compute.js) so the change guard settles the chain.
1307
1351
  recompute(event) {
1352
+ // Issue #183 — single-pass write set: an `input` event this method dispatched
1353
+ // for its OWN output writes is self-marked. Re-running the reducer on it would
1354
+ // re-enter from a partially-written DOM (the old mid-loop-dispatch corruption
1355
+ // class). Skip the reducer for our own event — but ONLY ours: the marker lives
1356
+ // in a per-instance WeakSet, so a genuinely different root's compute event (or
1357
+ // a real user edit) is never swallowed. The event still bubbled and fired every
1358
+ // OTHER listener (dirty tracking, show bindings, sibling roots) before reaching
1359
+ // here; we simply don't recompute a second time from our own write.
1360
+ if (event && this.#computeSelfDispatched.has(event)) return
1361
+
1308
1362
  // Inputs may be a JSON ARRAY of names (array form — every input coerced
1309
1363
  // through Number, the shipped behavior) or a JSON OBJECT of name→type (hash
1310
1364
  // form, issue #104 — :number coerced, :string read raw). #parseComputeInputs
@@ -1330,12 +1384,19 @@ export default class extends Controller {
1330
1384
  // The memo is per-CALL only: an output write dispatches `input` (issue #76),
1331
1385
  // re-entering recompute, which correctly rebuilds a fresh map (a morph may
1332
1386
  // have replaced the nodes) — it is NEVER stored on the instance.
1387
+ // Scope (issue #183, mirroring #showFieldValue): a bare compute name `cash`
1388
+ // under `data-reactive-scope="order"` resolves as `[name="order[cash]"]`. A
1389
+ // name already carrying a bracket (a raw wire name the author passed) is used
1390
+ // verbatim — so bracketed literals pass through unscoped.
1391
+ const scope = this.element.getAttribute?.("data-reactive-scope") || null
1392
+ const scoped = (name) => (scope && !name.includes("[") ? `${scope}[${name}]` : name)
1393
+
1333
1394
  const owns = this.#ownershipFilter()
1334
1395
  const byName = new Map()
1335
1396
  const ownedField = (name) => {
1336
1397
  if (byName.has(name)) return byName.get(name)
1337
1398
  let found = null
1338
- for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
1399
+ for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
1339
1400
  if (owns(el)) {
1340
1401
  found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
1341
1402
  break
@@ -1381,36 +1442,61 @@ export default class extends Controller {
1381
1442
 
1382
1443
  // meta.changed stays on #changedComputeField (its own #ownsField check over
1383
1444
  // the raw event target) — NOT this resolver. The issue-#15 nested-rejection
1384
- // test depends on that path being unchanged.
1385
- const result = reduce(values, { changed: this.#changedComputeField(event, inputs) }) || {}
1445
+ // test depends on that path being unchanged. ONE run, from the ONE pre-write
1446
+ // snapshot above (issue #183): its result drives the whole single-pass write.
1447
+ const result = reduce(values, { changed: this.#changedComputeField(event, inputs, scope) }) || {}
1448
+
1449
+ // Issue #183 — SINGLE-PASS WRITE SET. Three ordered phases, so declared output
1450
+ // order stops being semantics and a wrong order can no longer corrupt values:
1451
+ //
1452
+ // 1. BATCH the field writes from the ONE result. Each output name in the
1453
+ // allowlist (outputs:) whose owned field's value actually changes is
1454
+ // written now (change-guarded) and remembered — but NO `input` event is
1455
+ // dispatched yet, so nothing re-enters mid-batch.
1456
+ // 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
1457
+ // presence (issue #183 change #4 — a text node no longer needs its name in
1458
+ // outputs:), then the cross-root mirror: ids (issue #159).
1459
+ // 3. DISPATCH a self-marked `input` on each changed field. The marker (a
1460
+ // per-instance WeakSet) makes recompute skip re-running the reducer for our
1461
+ // own write, while the event still fires every OTHER listener (chained
1462
+ // repaint, dirty tracking, show bindings, sibling roots).
1463
+ const changedFields = []
1386
1464
  for (const name of outputs) {
1387
1465
  if (!(name in result)) continue
1388
1466
  const field = ownedField(name)
1389
- // Output resolution (issue #104): write to the owned named FIELD if one
1390
- // exists, ELSE mirror to every owned [data-reactive-text="<name>"] node.
1391
- if (field) {
1392
- // Real browsers do NOT fire `input` on a programmatic .value write (issue
1393
- // #76), so after writing we dispatch a bubbling `input` ourselves — that's
1394
- // what drives a chained repaint (a summary listener, a second compute),
1395
- // matching the server's set_value + dispatch("input") contract. The write
1396
- // is CHANGE-GUARDED: an unchanged value is skipped entirely (no write, no
1397
- // event). The guard is what lets a reducer with overlapping inputs/outputs
1398
- // (the shipped payment_split shape) settle — an unconditional dispatch
1399
- // would re-enter input->reactive#recompute forever.
1400
- if (String(result[name]) === field.value) continue
1401
- field.value = result[name]
1402
- field.dispatchEvent(new Event("input", { bubbles: true }))
1403
- } else {
1404
- // A text-node output: textContent, XSS-safe by construction. Change-
1405
- // guarded too (compare before writing), but NO input dispatch — a text
1406
- // node has no listener contract, so nothing chains off it.
1407
- this.#mirrorText(name, result[name])
1408
- }
1467
+ if (!field) continue // a non-field output paints as a text sink in phase 2
1468
+ if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
1469
+ field.value = result[name]
1470
+ changedFields.push(field)
1409
1471
  }
1410
1472
 
1411
- // Cross-root text mirrors (issue #159) AFTER the outputs are applied, so
1412
- // a mirror keyed on a just-written output paints the settled value.
1473
+ // Phase 2 — text sinks declare themselves (issue #183 change #4): every result
1474
+ // key paints into any owned [data-reactive-text="<name>"] node by PRESENCE,
1475
+ // regardless of outputs: membership. Runs from settled field values. A null/
1476
+ // undefined result value is SKIPPED (never stringified to "null"/"undefined") —
1477
+ // the same "no value this pass, don't paint" filter #applyComputeMirrors uses.
1478
+ for (const name of Object.keys(result)) {
1479
+ const value = result[name]
1480
+ if (value === undefined || value === null) continue
1481
+ this.#mirrorText(name, value)
1482
+ }
1483
+
1484
+ // Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
1485
+ // mirror keyed on a just-written output paints the settled value.
1413
1486
  this.#applyComputeMirrors(result, ownedField)
1487
+
1488
+ // Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
1489
+ // do NOT fire `input` on a programmatic .value write (issue #76), so we do it
1490
+ // ourselves, matching the server's set_value + dispatch("input") contract.
1491
+ // Each event is SELF-MARKED so our own re-entry skips the reducer (guard at the
1492
+ // top of recompute) — but the event still bubbles and fires every other
1493
+ // listener. Dispatched AFTER all writes + paints, so a chained listener reads
1494
+ // SETTLED values, never a half-written DOM.
1495
+ for (const field of changedFields) {
1496
+ const inputEvent = new Event("input", { bubbles: true })
1497
+ this.#computeSelfDispatched.add(inputEvent)
1498
+ field.dispatchEvent(inputEvent)
1499
+ }
1414
1500
  }
1415
1501
 
1416
1502
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -1517,11 +1603,26 @@ export default class extends Controller {
1517
1603
  // named form control OWNED by this root (not a nested reactive root's, issue
1518
1604
  // #15) AND its name is among the declared compute inputs; anything else
1519
1605
  // (a direct call, an unowned/undeclared target) yields null.
1520
- #changedComputeField(event, inputs) {
1606
+ //
1607
+ // Scope-aware (issue #184): under data-reactive-scope, the edited field's DOM
1608
+ // name is scoped (order[allowance]) while the declared inputs are BARE
1609
+ // (allowance). Strip the scope prefix off the DOM name before comparing, and
1610
+ // return the BARE name — so a reducer branching on `changed` sees the same
1611
+ // names it declared, scoped or not.
1612
+ #changedComputeField(event, inputs, scope) {
1521
1613
  const target = event?.target
1522
1614
  if (!target?.name || typeof target.closest !== "function") return null
1523
- if (!inputs.includes(target.name)) return null
1524
- return this.#ownsField(target) ? target.name : null
1615
+ const bare = this.#unscopeName(target.name, scope)
1616
+ if (!inputs.includes(bare)) return null
1617
+ return this.#ownsField(target) ? bare : null
1618
+ }
1619
+
1620
+ // Strip a leading `scope[…]` wrapper off a DOM field name, returning the bare
1621
+ // inner name; a name that isn't wrapped in this scope passes through unchanged.
1622
+ #unscopeName(name, scope) {
1623
+ if (!scope) return name
1624
+ const prefix = `${scope}[`
1625
+ return name.startsWith(prefix) && name.endsWith("]") ? name.slice(prefix.length, -1) : name
1525
1626
  }
1526
1627
 
1527
1628
  // Write `value` into every owned [data-reactive-text="<name>"] node via
@@ -1590,7 +1691,7 @@ export default class extends Controller {
1590
1691
  // Split out of dispatch so both the no-confirm fast path and the post-confirm
1591
1692
  // microtask share one place (issue #55). `target` is captured up front because
1592
1693
  // this can run in a later microtask, after event.target has been reset.
1593
- #proceed(target, action, params, debounce, throttle, optimistic, loading) {
1694
+ #proceed(target, action, params, debounce, throttle, optimistic, busy) {
1594
1695
  // Lifecycle veto point (issue #79): one cancelable reactive:before-dispatch
1595
1696
  // per user gesture — post-preventDefault, post-confirm, PRE-debounce (and
1596
1697
  // PRE-throttle, the same timing). event.preventDefault() skips the
@@ -1608,21 +1709,21 @@ export default class extends Controller {
1608
1709
  // Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
1609
1710
  // coalesce rapid events into ONE round trip after a quiet period, instead of
1610
1711
  // one POST per keystroke (issue #17). A blur flushes a pending dispatch.
1611
- // The optimistic hint (issue #98) and the loading state (issue #99) ride to
1712
+ // The optimistic hint (issue #98) and the busy state (issue #181) ride to
1612
1713
  // the flush too, so they apply ONCE per enqueue — a debounced input must not
1613
1714
  // flap toggle_class per keystroke, and its element must NOT be disabled
1614
1715
  // during the quiet period (that would break typing). Both apply at ENQUEUE.
1615
1716
  const ms = Number(debounce) || 0
1616
- if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, loading)
1717
+ if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, busy)
1617
1718
 
1618
1719
  // Throttled trigger (e.g. on(:track, event: "scroll", window: true,
1619
1720
  // throttle: 250), issue #80): LEADING-EDGE rate limit — fire the first
1620
1721
  // event immediately, drop the rest until the window elapses. debounce and
1621
1722
  // throttle are mutually exclusive (the Ruby on() raises on both).
1622
1723
  const throttleMs = Number(throttle) || 0
1623
- if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, loading)
1724
+ if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, busy)
1624
1725
 
1625
- return this.#enqueue(action, params, optimistic, target, loading)
1726
+ return this.#enqueue(action, params, optimistic, target, busy)
1626
1727
  }
1627
1728
 
1628
1729
  // Apply the optimistic hint ONCE (recording its inverse) and chain the round
@@ -1631,29 +1732,55 @@ export default class extends Controller {
1631
1732
  // #98). Applying here — the single flush/enqueue point every path funnels
1632
1733
  // through — is what makes a hint apply once per enqueue, not per raw dispatch.
1633
1734
  //
1634
- // The loading state (issue #99) applies here too, for the same reason: enqueue
1735
+ // The busy state (issue #181) applies here too, for the same reason: enqueue
1635
1736
  // is the moment the request is committed to the queue, so the always-on busy
1636
1737
  // vocabulary (data-reactive-busy on the trigger + root, aria-busy via a pending
1637
- // counter, busy_on scoping) and the loading hint (disable + class + text swap)
1738
+ // counter, busy_on scoping) and the busy hint (disable + class + text swap)
1638
1739
  // cover the WHOLE pending window — queue wait included — not just the fetch. It
1639
1740
  // returns a `settle` closure that #perform runs in its finally (success OR
1640
1741
  // failure), guarded so a morph-replaced trigger is never clobbered.
1641
- #enqueue(action, params, optimistic, target, loading) {
1742
+ #enqueue(action, params, optimistic, target, busy) {
1642
1743
  const inverse = this.#applyOptimistic(optimistic, target)
1643
- const settle = this.#applyLoading(action, target, loading)
1644
- this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params, inverse, settle))
1744
+ const settle = this.#applyBusy(action, target, busy)
1745
+ // Debug-only teaching aid (issue #181): if optimistic: { hide: true } is used
1746
+ // for instant-delete but the reply RE-RENDERS the element (bringing it back),
1747
+ // that hint was pointless — the developer likely wanted reply.remove. Capture
1748
+ // the hidden nodes now; the success path re-checks the OBSERVED DOM after the
1749
+ // morph (never inferred from the verb) and warns if any came back visible.
1750
+ const resurrect = this.#debugEnabled() ? this.#buildResurrectionCheck(optimistic, target) : null
1751
+ this.queue = (this.queue ?? Promise.resolve())
1752
+ .then(() => this.#perform(action, params, inverse, settle, resurrect))
1645
1753
  return this.queue
1646
1754
  }
1647
1755
 
1756
+ // Snapshot the elements an optimistic hide: targeted (the trigger, or the `to:`
1757
+ // selector) so the success path can detect a resurrection. Returns null unless
1758
+ // a hide: hint is present — nothing else can be "resurrected".
1759
+ #buildResurrectionCheck(optimistic, target) {
1760
+ if (!optimistic?.hide) return null
1761
+ const hidden = this.#hintTargets(optimistic, target)
1762
+ if (!hidden.length) return null
1763
+ return () => {
1764
+ const back = hidden.filter((el) => el.isConnected && !el.hidden)
1765
+ if (!back.length) return
1766
+ console.warn(
1767
+ "[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — " +
1768
+ "the element is visible again. For an instant delete, return reply.remove so the " +
1769
+ "server removes it; otherwise the hide only flashes.",
1770
+ back,
1771
+ )
1772
+ }
1773
+ }
1774
+
1648
1775
  // Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
1649
1776
  // Also flush immediately on blur so leaving the field never drops the last
1650
1777
  // edit (a long debounce shouldn't swallow a value the user tabbed away from).
1651
- #debounceDispatch(target, ms, action, params, optimistic, loading) {
1778
+ #debounceDispatch(target, ms, action, params, optimistic, busy) {
1652
1779
  this.#clearDebounce(target)
1653
1780
 
1654
1781
  const flush = () => {
1655
1782
  this.#clearDebounce(target)
1656
- this.#enqueue(action, params, optimistic, target, loading)
1783
+ this.#enqueue(action, params, optimistic, target, busy)
1657
1784
  }
1658
1785
  const timer = setTimeout(flush, ms)
1659
1786
  target?.addEventListener?.("blur", flush, { once: true })
@@ -1681,7 +1808,7 @@ export default class extends Controller {
1681
1808
  // are keyed on action + target, NOT target alone: window-bound scroll/resize
1682
1809
  // events all share event.target === document, so two window-bound triggers
1683
1810
  // on one component would otherwise collide on one timer.
1684
- #throttleDispatch(target, ms, action, params, optimistic, loading) {
1811
+ #throttleDispatch(target, ms, action, params, optimistic, busy) {
1685
1812
  const timers = this.#throttleTimers.get(target) ?? new Map()
1686
1813
  if (timers.has(action)) return // inside the window — suppress
1687
1814
 
@@ -1691,7 +1818,7 @@ export default class extends Controller {
1691
1818
  }, ms)
1692
1819
  timers.set(action, timer)
1693
1820
  this.#throttleTimers.set(target, timers)
1694
- return this.#enqueue(action, params, optimistic, target, loading) // leading edge: fire NOW
1821
+ return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
1695
1822
  }
1696
1823
 
1697
1824
  // Clear every throttle suppression timer (used on disconnect, alongside
@@ -1852,7 +1979,7 @@ export default class extends Controller {
1852
1979
  /* eslint-enable no-console */
1853
1980
  }
1854
1981
 
1855
- async #perform(action, params, inverse, settle) {
1982
+ async #perform(action, params, inverse, settle, resurrect) {
1856
1983
  // Auto-collect named field values inside this component so a button-
1857
1984
  // triggered action still receives sibling inputs (Livewire-style), plus any
1858
1985
  // chosen file inputs in the SAME walk. Explicit params
@@ -2046,6 +2173,10 @@ export default class extends Controller {
2046
2173
  // replace (Response.morph) or an update morphs in place, preserving the
2047
2174
  // focused input + caret on unchanged nodes — see issue #28.
2048
2175
  window.Turbo.renderStreamMessage(html)
2176
+ // Debug-only (issue #181): the morph may apply a microtask later, so check
2177
+ // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
2178
+ // null — zero cost.
2179
+ if (resurrect) queueMicrotask(resurrect)
2049
2180
  // A successful apply CLEARS any prior failure marker (issue #100), so
2050
2181
  // error-driven CSS on the root (a red border, a shake) resets on recovery.
2051
2182
  this.#clearError()
@@ -2192,6 +2323,54 @@ export default class extends Controller {
2192
2323
  return (el) => this.#ownsField(el)
2193
2324
  }
2194
2325
 
2326
+ // Resolve the effective confirm message (issue #179). A plain string is the
2327
+ // static #52 form (always shown). A confirmWhen JSON payload is the CONDITIONAL
2328
+ // form — evaluated over the SAME collected fields reactive_compute reads — and
2329
+ // returns the message ONLY when it fires, else null (proceed, no dialog):
2330
+ // { groups, message } — the reactive_show conditions fold (anyOfAllsMatches)
2331
+ // { predicate, message } — a registered fn (setConfirmPredicate) over the fields
2332
+ // A missing predicate warns and returns null (PROCEED without a dialog) — the
2333
+ // compute unknown-reducer posture. This is soft-validation UX; the endpoint's
2334
+ // authorize/default-deny is the real gate, so failing OPEN here never grants
2335
+ // anything the server wouldn't already allow.
2336
+ #effectiveConfirmMessage(confirm, confirmWhen) {
2337
+ if (confirm) return confirm
2338
+ if (!confirmWhen) return null
2339
+
2340
+ // Stimulus auto-parses a JSON-object -param value, so confirmWhen usually
2341
+ // arrives ALREADY parsed. Accept an object as-is; parse a string defensively
2342
+ // (a hand-built attr, or a non-Stimulus caller). A malformed string warns and
2343
+ // proceeds without a dialog (default-deny UX — the server is the real gate).
2344
+ let payload = confirmWhen
2345
+ if (typeof confirmWhen === "string") {
2346
+ try {
2347
+ payload = JSON.parse(confirmWhen)
2348
+ } catch {
2349
+ console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(confirmWhen)} — skipped`)
2350
+ return null
2351
+ }
2352
+ }
2353
+ if (!payload || typeof payload !== "object") return null
2354
+
2355
+ const { fields } = this.#collectFields()
2356
+ const fieldValue = (name) => fields[name]
2357
+
2358
+ let fires
2359
+ if (typeof payload.predicate === "string") {
2360
+ const fn = confirmPredicate(payload.predicate)
2361
+ if (!fn) {
2362
+ console.warn(`[phlex-reactive] confirm predicate "${payload.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`)
2363
+ return null
2364
+ }
2365
+ fires = !!fn(fields)
2366
+ } else {
2367
+ // Declarative: the DNF groups fold, identical to reactive_show — matches → fire.
2368
+ fires = anyOfAllsMatches(payload.groups?.any, fieldValue) === true
2369
+ }
2370
+
2371
+ return fires ? payload.message : null
2372
+ }
2373
+
2195
2374
  // One walk over THIS root's named controls (not a nested reactive root's),
2196
2375
  // returning both the scalar `fields` and any chosen `files`. The ownership
2197
2376
  // predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
@@ -2723,79 +2902,30 @@ export default class extends Controller {
2723
2902
  return type === "checkbox" || type === "radio"
2724
2903
  }
2725
2904
 
2726
- // Apply the optimistic hint (issue #98) to its targets NOW and return the
2727
- // INVERSE — the exact ops to replay on failure. Cosmetic only: class ops and
2728
- // hidden, applied to the trigger by default or to a `to:` selector scoped to
2729
- // the root. `checked: :keep` records the trigger's post-flip state so a
2730
- // failure snaps the native control back; it applies no DOM change itself (the
2731
- // browser already flipped it). Returns null when there is nothing to do, so
2732
- // the success/failure paths can cheaply skip.
2905
+ // Apply the OPTIMISTIC hint (issue #98) NOW and return its `undo` closure the
2906
+ // exact ops to replay on FAILURE (optimistic reverts only when the round trip
2907
+ // fails; success leaves server truth or the deliberately-standing hint). It is
2908
+ // the same op vocabulary busy: uses (issue #181) via the one #applyHint engine;
2909
+ // the ONLY optimistic-specific op is checked: :keep (honorChecked = true).
2733
2910
  #applyOptimistic(optimistic, trigger) {
2734
2911
  if (!optimistic) return null
2735
-
2736
- // Class + hidden ops share one target set (trigger, or the `to:` selector).
2737
- const targets = this.#optimisticTargets(optimistic, trigger)
2738
- const undo = []
2739
- for (const el of targets) {
2740
- if (optimistic.add_class) {
2741
- // Undo only the classes this op ACTUALLY added — a class already present
2742
- // was not our change, so reverting it would strip a class the element
2743
- // legitimately had (the add was a no-op). Capture the real delta now.
2744
- const added = optimistic.add_class.filter((c) => !el.classList.contains(c))
2745
- el.classList.add(...added)
2746
- if (added.length) undo.push(() => el.classList.remove(...added))
2747
- }
2748
- if (optimistic.remove_class) {
2749
- // Symmetric: undo only the classes actually removed — one already absent
2750
- // wasn't our change, so re-adding it would introduce a class that wasn't
2751
- // there before.
2752
- const removed = optimistic.remove_class.filter((c) => el.classList.contains(c))
2753
- el.classList.remove(...removed)
2754
- if (removed.length) undo.push(() => el.classList.add(...removed))
2755
- }
2756
- if (optimistic.toggle_class) {
2757
- // toggle_class is its own inverse regardless of prior state — toggling
2758
- // the same classes back exactly restores it, no delta tracking needed.
2759
- optimistic.toggle_class.forEach((c) => el.classList.toggle(c))
2760
- undo.push(() => optimistic.toggle_class.forEach((c) => el.classList.toggle(c)))
2761
- }
2762
- if (optimistic.hide) {
2763
- el.hidden = true
2764
- undo.push(() => (el.hidden = false))
2765
- }
2766
- }
2767
-
2768
- // checked: :keep — the native flip already happened on the trigger; record
2769
- // the inverse (flip it back) so a failure reverts the control's state.
2770
- if (optimistic.checked === "keep" && trigger && "checked" in trigger) {
2771
- const flipped = trigger.checked
2772
- undo.push(() => (trigger.checked = !flipped))
2773
- }
2774
-
2912
+ const undo = this.#applyHint(optimistic, trigger, true)
2775
2913
  return undo.length ? undo : null
2776
2914
  }
2777
2915
 
2778
- // Replay the recorded inverse ops on failure (issue #98), guarded by
2779
- // isConnected: a plain (non-morph) replace can detach this subtree before the
2780
- // failure lands, and reverting a stale/detached node is pointless (it's gone)
2781
- // — so a disconnected root skips the revert entirely. On success NOTHING calls
2782
- // this: the server re-render overwrites the hint, or (reply.remove /
2783
- // streams-only) the hint is deliberately left standing.
2916
+ // Replay the recorded undo ops on failure (issue #98), guarded by isConnected:
2917
+ // a plain (non-morph) replace can detach this subtree before the failure lands,
2918
+ // and reverting a stale/detached node is pointless (it's gone) — so a
2919
+ // disconnected root skips the revert entirely. On success NOTHING calls this:
2920
+ // the server re-render overwrites the hint, or (reply.remove / streams-only)
2921
+ // the hint is deliberately left standing.
2784
2922
  #revertOptimistic(inverse) {
2785
2923
  if (!inverse) return
2786
2924
  if (!this.element.isConnected) return
2787
2925
  for (const undo of inverse) undo()
2788
2926
  }
2789
2927
 
2790
- // The elements an optimistic class/hidden hint applies to: the `to:` selector
2791
- // (resolved like an op target — "@root" is the root, a selector is scoped to
2792
- // this root's owned matches) or, with no `to:`, the trigger itself.
2793
- #optimisticTargets(optimistic, trigger) {
2794
- if (optimistic.to == null) return trigger ? [trigger] : []
2795
- return this.#opTargets({ to: optimistic.to })
2796
- }
2797
-
2798
- // Apply the loading state for THIS enqueue (issue #99) and return a `settle`
2928
+ // Apply the BUSY state for THIS enqueue (issue #181) and return a `settle`
2799
2929
  // closure that undoes exactly this enqueue's contribution when the round trip
2800
2930
  // finishes (success OR any failure). Everything is refcounted so overlapping
2801
2931
  // enqueues never clobber: A's settle can't clear busy while B is still pending.
@@ -2806,23 +2936,121 @@ export default class extends Controller {
2806
2936
  // space-separated, per-action refcounted set), aria-busy on the root (a
2807
2937
  // pending counter), and data-reactive-busy on any busy_on element scoped
2808
2938
  // to this action. Apps style a spinner with pure CSS and zero Ruby.
2809
- // 2. The loading HINT (only when loading:/disable_with: was declared):
2810
- // disable the trigger, add a loading class (to the trigger or a `to:`
2811
- // target), swap its text. These apply at ENQUEUE never during a debounce
2812
- // quiet periodso a debounced input is not disabled mid-typing.
2813
- #applyLoading(action, trigger, loading) {
2939
+ // 2. The busy HINT (only when busy: was declared): the SAME cosmetic op set
2940
+ // as optimistic: (class ops, hide/show, disable, text), applied through
2941
+ // the one #applyHint engine and reverted on SETTLE (not on failure). These
2942
+ // apply at ENQUEUE never during a debounce quiet period so a debounced
2943
+ // input is not disabled mid-typing. checked: is optimistic-only, so busy:
2944
+ // passes honorChecked = false (the Ruby on() already rejects it — this is
2945
+ // belt-and-braces).
2946
+ #applyBusy(action, trigger, busy) {
2814
2947
  this.#markBusy(action, trigger)
2815
- const restoreHint = this.#applyLoadingHint(action, trigger, loading)
2948
+ const undo = busy ? this.#applyHint(busy, trigger, false) : []
2816
2949
 
2817
2950
  let settled = false
2818
2951
  return () => {
2819
2952
  if (settled) return // one settle per enqueue, even if called twice
2820
2953
  settled = true
2821
2954
  this.#unmarkBusy(action, trigger)
2822
- restoreHint()
2955
+ // Busy reverts on SETTLE regardless of outcome, guarded per element.
2956
+ for (const op of undo) op()
2823
2957
  }
2824
2958
  }
2825
2959
 
2960
+ // The ONE pending-state hint engine (issue #181), shared by optimistic: (revert
2961
+ // on failure) and busy: (revert on settle) — they differ only in WHEN the
2962
+ // returned undo ops run, never in the ops themselves. Applies the hint's
2963
+ // cosmetic ops to their targets (the trigger by default, or a `to:` selector
2964
+ // scoped to the root) and returns an array of undo closures. Class ops and
2965
+ // hide/show use a DELTA inverse (undo only what THIS call changed, so it
2966
+ // composes across overlapping enqueues); disable/text use a REFCOUNTED snapshot
2967
+ // (the true pre-hint value survives an overlapping enqueue that would otherwise
2968
+ // capture the already-swapped label as the "original"). `honorChecked` gates
2969
+ // checked: :keep — an optimistic-only native-control revert.
2970
+ #applyHint(hint, trigger, honorChecked) {
2971
+ const undo = []
2972
+ for (const el of this.#hintTargets(hint, trigger)) {
2973
+ if (hint.add_class) {
2974
+ // Undo only the classes this op ACTUALLY added — a class already present
2975
+ // was not our change, so reverting it would strip a class the element
2976
+ // legitimately had. Capture the real delta now.
2977
+ const added = hint.add_class.filter((c) => !el.classList.contains(c))
2978
+ el.classList.add(...added)
2979
+ if (added.length) undo.push(() => el.classList.remove(...added))
2980
+ }
2981
+ if (hint.remove_class) {
2982
+ // Symmetric: undo only the classes actually removed.
2983
+ const removed = hint.remove_class.filter((c) => el.classList.contains(c))
2984
+ el.classList.remove(...removed)
2985
+ if (removed.length) undo.push(() => el.classList.add(...removed))
2986
+ }
2987
+ if (hint.toggle_class) {
2988
+ // toggle_class is its own inverse regardless of prior state.
2989
+ hint.toggle_class.forEach((c) => el.classList.toggle(c))
2990
+ undo.push(() => hint.toggle_class.forEach((c) => el.classList.toggle(c)))
2991
+ }
2992
+ if (hint.hide) {
2993
+ el.hidden = true
2994
+ undo.push(() => (el.hidden = false))
2995
+ }
2996
+ if (hint.show) {
2997
+ el.hidden = false
2998
+ undo.push(() => (el.hidden = true))
2999
+ }
3000
+ }
3001
+
3002
+ // disable/text swap the TRIGGER (a `to:` retargets only the class/hide/show
3003
+ // ops above — disable/text are inherently trigger affordances). Refcounted so
3004
+ // overlapping enqueues restore correctly.
3005
+ if (trigger && (hint.disable || hint.text != null)) {
3006
+ undo.push(this.#applyTextDisable(hint, trigger))
3007
+ }
3008
+
3009
+ // checked: :keep — the native flip already happened on the trigger; record
3010
+ // the inverse (flip it back) so a revert restores the control's state.
3011
+ if (honorChecked && hint.checked === "keep" && trigger && "checked" in trigger) {
3012
+ const flipped = trigger.checked
3013
+ undo.push(() => (trigger.checked = !flipped))
3014
+ }
3015
+
3016
+ return undo
3017
+ }
3018
+
3019
+ // The elements a hint's class/hide/show ops apply to: the `to:` selector
3020
+ // (resolved like an op target — "@root" is the root, a selector is scoped to
3021
+ // this root's owned matches) or, with no `to:`, the trigger itself.
3022
+ #hintTargets(hint, trigger) {
3023
+ if (hint.to == null) return trigger ? [trigger] : []
3024
+ return this.#opTargets({ to: hint.to })
3025
+ }
3026
+
3027
+ // Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the
3028
+ // ORIGINAL once per trigger (refcounted so an overlapping enqueue never
3029
+ // snapshots the already-swapped "Saving…" as the original), and return the undo
3030
+ // closure. text swaps innerHTML (issue #181), NOT textContent: a composite
3031
+ // trigger like `<button><svg/> Save</button>` has child nodes, and
3032
+ // textContent = "Saving…" would DESTROY the icon; innerHTML preserves the
3033
+ // markup structure and restores it byte-for-byte.
3034
+ #applyTextDisable(hint, trigger) {
3035
+ const snap = this.#textDisableSnapshots.get(trigger)
3036
+ if (snap) {
3037
+ snap.count++
3038
+ } else {
3039
+ this.#textDisableSnapshots.set(trigger, {
3040
+ count: 1,
3041
+ disabled: trigger.disabled,
3042
+ html: trigger.innerHTML,
3043
+ hadText: hint.text != null,
3044
+ swappedTo: hint.text,
3045
+ })
3046
+ }
3047
+
3048
+ if (hint.disable) trigger.disabled = true
3049
+ if (hint.text != null) trigger.innerHTML = hint.text
3050
+
3051
+ return () => this.#restoreTextDisable(trigger, hint)
3052
+ }
3053
+
2826
3054
  // Layer 1 — the always-on busy markers. Trigger + root carry the action token;
2827
3055
  // the root's counter drives aria-busy; busy_on elements scoped to this action
2828
3056
  // light up. Refcounts (#busyActions, #busyPending) so overlapping requests
@@ -2886,72 +3114,35 @@ export default class extends Controller {
2886
3114
  )
2887
3115
  }
2888
3116
 
2889
- // Layer 2 — the loading HINT (disable + class + text). Snapshots the trigger's
2890
- // ORIGINAL disabled/text/classes on the FIRST enqueue for that trigger
2891
- // (refcounted so an overlapping enqueue never snapshots the already-swapped
2892
- // "Saving…" as the original), applies the swap, and returns a restore closure.
2893
- // With no hint, returns a no-op restore (the always-on busy markers still ran).
2894
- #applyLoadingHint(action, trigger, loading) {
2895
- if (!loading || !trigger) return () => {}
2896
-
2897
- const classTargets = this.#loadingTargets(loading, trigger)
2898
- const classes = Array.isArray(loading.class) ? loading.class : []
2899
- const addedByTarget = []
2900
- for (const el of classTargets) {
2901
- const added = classes.filter((c) => !el.classList.contains(c))
2902
- el.classList.add(...added)
2903
- if (added.length) addedByTarget.push([el, added])
2904
- }
2905
-
2906
- // Snapshot disabled/text ONCE per trigger (refcounted). A second overlapping
2907
- // enqueue increments the count but does NOT re-snapshot — so the recorded
2908
- // "original" is the true pre-loading state, never the swapped label.
2909
- const snap = this.#loadingSnapshots.get(trigger)
2910
- if (snap) {
2911
- snap.count++
2912
- } else if (loading.disable || loading.text != null) {
2913
- this.#loadingSnapshots.set(trigger, {
2914
- count: 1,
2915
- disabled: trigger.disabled,
2916
- text: trigger.textContent,
2917
- hadText: loading.text != null,
2918
- })
2919
- }
2920
-
2921
- if (loading.disable) trigger.disabled = true
2922
- if (loading.text != null) trigger.textContent = loading.text
2923
-
2924
- return () => {
2925
- for (const [el, added] of addedByTarget) if (el.isConnected) el.classList.remove(...added)
2926
- this.#restoreLoadingSnapshot(trigger, loading)
2927
- }
2928
- }
2929
-
2930
- // Restore the trigger's disabled/text from its snapshot when the LAST enqueue
2931
- // for that trigger settles (refcount → 0). GUARDED: skip a disconnected
3117
+ // Restore the trigger's disabled/innerHTML from its snapshot when the LAST
3118
+ // enqueue for that trigger settles (refcount 0). GUARDED: skip a disconnected
2932
3119
  // trigger (a plain replace detached it — the node is gone), and do NOT restore
2933
- // the text if it no longer equals what we swapped IN (a morph rendered a new
2934
- // server label — clobbering it with the old text would fight server truth).
2935
- #restoreLoadingSnapshot(trigger, loading) {
2936
- const snap = this.#loadingSnapshots.get(trigger)
3120
+ // the label if it no longer equals what we swapped IN (a morph rendered a new
3121
+ // server label — clobbering it with the old markup would fight server truth).
3122
+ // The comparison + restore both use innerHTML so a composite trigger (icon +
3123
+ // label) round-trips its full markup, not a flattened text run (issue #181).
3124
+ #restoreTextDisable(trigger, hint) {
3125
+ const snap = this.#textDisableSnapshots.get(trigger)
2937
3126
  if (!snap) return
2938
3127
  if (--snap.count > 0) return // another enqueue for this trigger is still pending
2939
- this.#loadingSnapshots.delete(trigger)
3128
+ this.#textDisableSnapshots.delete(trigger)
2940
3129
 
2941
3130
  if (!trigger.isConnected) return // detached — nothing to restore
2942
3131
 
2943
- if (loading.disable) trigger.disabled = snap.disabled
2944
- // Only restore the label if the trigger still shows OUR swapped text; a
2945
- // changed textContent means the server morph relabeled it — leave it.
2946
- if (snap.hadText && trigger.textContent === loading.text) trigger.textContent = snap.text
3132
+ if (hint.disable) trigger.disabled = snap.disabled
3133
+ if (snap.hadText && trigger.innerHTML === snap.swappedTo) trigger.innerHTML = snap.html
2947
3134
  }
2948
3135
 
2949
- // The elements a loading class applies to: the `to:` selector (resolved like
2950
- // an op target "@root" is the root, a selector is scoped to this root's
2951
- // owned matches) or, with no `to:`, the trigger itself.
2952
- #loadingTargets(loading, trigger) {
2953
- if (loading.to == null) return trigger ? [trigger] : []
2954
- return this.#opTargets({ to: loading.to })
3136
+ // Deploy-overlap read shim (issue #181): a page still rendered by the PREVIOUS
3137
+ // gem emits the old data-reactive-loading-param, whose `class:` key is the busy
3138
+ // vocabulary's `add_class:`. Remap it so an in-flight legacy page keeps its
3139
+ // pending affordance through the one #applyHint engine. Returns null for the
3140
+ // common (no legacy param) case so the fast path is untouched. Drop this shim
3141
+ // one minor after #181 ships (no page can still carry the old attr by then).
3142
+ #legacyLoadingHint(loading) {
3143
+ if (!loading || typeof loading !== "object") return null
3144
+ const { class: cls, ...rest } = loading
3145
+ return cls == null ? loading : { ...rest, add_class: cls }
2955
3146
  }
2956
3147
 
2957
3148
  // The action path comes from a <meta> tag that is fixed for the page's life,