phlex-reactive 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +265 -0
- data/README.md +127 -78
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +417 -179
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +122 -34
- data/lib/phlex/reactive/component/helpers.rb +314 -166
- data/lib/phlex/reactive/component/registry.rb +6 -1
- data/lib/phlex/reactive/component.rb +3 -3
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/inspector.rb +5 -1
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/streamable.rb +229 -199
- data/lib/phlex/reactive/test_helpers.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +4 -1
|
@@ -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
|
-
#
|
|
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
|
|
@@ -1023,6 +1031,9 @@ export default class extends Controller {
|
|
|
1023
1031
|
// Option filtering (issue #163): the ONE delegated sync handler shared by the
|
|
1024
1032
|
// root's input/turbo:morph-element listeners, held for teardown.
|
|
1025
1033
|
#boundSyncFilter
|
|
1034
|
+
// Connect-time compute seed (issue #199): the bound re-seed attached to
|
|
1035
|
+
// turbo:morph-element so an in-place morph re-runs the compute, held for teardown.
|
|
1036
|
+
#boundSeedCompute
|
|
1026
1037
|
// Lazy initial mount (issue #165): the bound re-probe attached to
|
|
1027
1038
|
// turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
|
|
1028
1039
|
#boundProbeLazyDefer
|
|
@@ -1128,6 +1139,32 @@ export default class extends Controller {
|
|
|
1128
1139
|
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncFilter)
|
|
1129
1140
|
this.#syncFilter()
|
|
1130
1141
|
}
|
|
1142
|
+
|
|
1143
|
+
// Connect-time compute seed (issue #199) — ONLY when the root carries a
|
|
1144
|
+
// reactive_compute binding that opts in (data-reactive-compute-seed). A
|
|
1145
|
+
// freshly-rendered compute root (a first paint, or a server validation-error
|
|
1146
|
+
// re-render that replaced the body) computed NOTHING until the first user
|
|
1147
|
+
// `input`; apps worked around it by dispatching a synthetic seed `input` on
|
|
1148
|
+
// connect, but the compute root is a distinct Stimulus controller that may
|
|
1149
|
+
// connect a frame later, so the seed raced its own wiring — the reported
|
|
1150
|
+
// symptom being a PARTIAL apply (an early output paints; a later output + the
|
|
1151
|
+
// mirror stay blank). Running ONE recompute() HERE — after Stimulus has fully
|
|
1152
|
+
// connected the controller and wired the input->recompute delegation — runs
|
|
1153
|
+
// the whole single-pass write set (issue #183) synchronously, so every
|
|
1154
|
+
// declared output, text sink, and cross-root mirror paints from one reducer
|
|
1155
|
+
// result. It is client-only (recompute never enqueues a round trip) and
|
|
1156
|
+
// idempotent (change-guarded writes make a re-seed a no-op — an app still
|
|
1157
|
+
// dispatching a synthetic input is harmless). A plain replace re-connects and
|
|
1158
|
+
// re-seeds; an in-place morph keeps the element CONNECTED and fires no
|
|
1159
|
+
// Stimulus lifecycle, so ALSO re-seed on turbo:morph-element (the show/filter/
|
|
1160
|
+
// dirty precedent). No event is passed, so meta.changed is null — the correct
|
|
1161
|
+
// "no field edited yet" seed semantics; a convergent reducer's default branch
|
|
1162
|
+
// computes the full settled set (see compute.js CONVERGENCE REQUIREMENT).
|
|
1163
|
+
if (this.#computeSeedEnabled()) {
|
|
1164
|
+
this.#boundSeedCompute = () => this.recompute()
|
|
1165
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute)
|
|
1166
|
+
this.recompute()
|
|
1167
|
+
}
|
|
1131
1168
|
}
|
|
1132
1169
|
|
|
1133
1170
|
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
|
|
@@ -1154,6 +1191,7 @@ export default class extends Controller {
|
|
|
1154
1191
|
this.#teardownDirtyTracking()
|
|
1155
1192
|
this.#teardownShowSync()
|
|
1156
1193
|
this.#teardownFilterSync()
|
|
1194
|
+
this.#teardownComputeSeed()
|
|
1157
1195
|
if (this.#boundProbeLazyDefer) {
|
|
1158
1196
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
1159
1197
|
}
|
|
@@ -1186,10 +1224,17 @@ export default class extends Controller {
|
|
|
1186
1224
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
1187
1225
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
1188
1226
|
// Stimulus descriptor.
|
|
1189
|
-
const { action, params, debounce, throttle, confirm, outside, window: windowBound, optimistic
|
|
1227
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
1190
1228
|
event.params
|
|
1191
1229
|
if (!action) return
|
|
1192
1230
|
|
|
1231
|
+
// The pending-state hint (issue #181): data-reactive-busy-param. During a
|
|
1232
|
+
// deploy overlap a page rendered by the PREVIOUS gem still emits the old
|
|
1233
|
+
// data-reactive-loading-param — read it as a fallback so an in-flight page
|
|
1234
|
+
// keeps its pending affordance until the next full render. The old `class:`
|
|
1235
|
+
// key is remapped to add_class: so it flows through the one hint applier.
|
|
1236
|
+
const busy = event.params.busy ?? this.#legacyLoadingHint(event.params.loading)
|
|
1237
|
+
|
|
1193
1238
|
// Outside guard FIRST (issue #80): an outside: trigger only fires for
|
|
1194
1239
|
// events whose target is OUTSIDE this component's ROOT (containment against
|
|
1195
1240
|
// this.element — .contains includes the root itself). An event inside the
|
|
@@ -1230,8 +1275,14 @@ export default class extends Controller {
|
|
|
1230
1275
|
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
1231
1276
|
if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
1232
1277
|
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1278
|
+
// Resolve the EFFECTIVE confirm message (issue #179): a plain string confirm:
|
|
1279
|
+
// is that string (static, #52); a Hash confirm: (confirmWhen) evaluates its
|
|
1280
|
+
// condition/predicate over the collected fields and returns the message ONLY
|
|
1281
|
+
// when it fires, else null → no dialog. No confirm at all → also null.
|
|
1282
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
1283
|
+
|
|
1284
|
+
// No message → proceed straight away (unchanged fast path).
|
|
1285
|
+
if (!message) return this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
|
|
1235
1286
|
|
|
1236
1287
|
// Confirmation gate (issue #52, made overridable + async in #55). A reactive
|
|
1237
1288
|
// trigger can't use Hotwire's data-turbo-confirm — this controller preempts
|
|
@@ -1245,10 +1296,10 @@ export default class extends Controller {
|
|
|
1245
1296
|
// genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
|
|
1246
1297
|
// truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
|
|
1247
1298
|
Promise.resolve()
|
|
1248
|
-
.then(() => confirmResolver(
|
|
1299
|
+
.then(() => confirmResolver(message))
|
|
1249
1300
|
.catch(() => false)
|
|
1250
1301
|
.then((ok) => {
|
|
1251
|
-
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic,
|
|
1302
|
+
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
|
|
1252
1303
|
})
|
|
1253
1304
|
}
|
|
1254
1305
|
|
|
@@ -1259,7 +1310,7 @@ export default class extends Controller {
|
|
|
1259
1310
|
// the component resets whatever they toggled (by design — a signed action
|
|
1260
1311
|
// owns state that must survive re-renders).
|
|
1261
1312
|
runOps(event) {
|
|
1262
|
-
const { ops, outside, window: windowBound } = event.params
|
|
1313
|
+
const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
|
|
1263
1314
|
|
|
1264
1315
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
1265
1316
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
@@ -1269,10 +1320,33 @@ export default class extends Controller {
|
|
|
1269
1320
|
// Element-bound triggers preventDefault (a bare button inside a <form>
|
|
1270
1321
|
// must not submit it); window-bound triggers (window:/outside:) never do —
|
|
1271
1322
|
// they hear every matching event on the page, and preventDefault-ing those
|
|
1272
|
-
// would kill native clicks site-wide (issue #80 rationale).
|
|
1323
|
+
// would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
|
|
1324
|
+
// (possibly async) confirm gate below — a native default can't wait for a
|
|
1325
|
+
// pending dialog (same ordering as dispatch()).
|
|
1273
1326
|
if (!windowBound) event.preventDefault()
|
|
1274
1327
|
|
|
1275
|
-
|
|
1328
|
+
// Resolve the effective confirm message — static string, or the conditional
|
|
1329
|
+
// Hash form (issue #179) evaluated over collected fields. Null → no dialog.
|
|
1330
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
1331
|
+
|
|
1332
|
+
// No message → apply straight away (unchanged fast path, no prompt).
|
|
1333
|
+
if (!message) return this.#applyOps(this.#parseOps(ops))
|
|
1334
|
+
|
|
1335
|
+
// Confirmation gate for client ops (issue #178) — the SAME confirmResolver
|
|
1336
|
+
// gate on(:action, confirm:) uses (issues #52/#55), reused verbatim so a
|
|
1337
|
+
// themed dialog set with setConfirmResolver covers BOTH paths. A destructive
|
|
1338
|
+
// client op (clear a draft, reset a form) gets the one-line themed confirm
|
|
1339
|
+
// without a round trip. Call the resolver INSIDE the chain (leading .then)
|
|
1340
|
+
// so even a SYNCHRONOUS override throw rejects here instead of escaping
|
|
1341
|
+
// runOps — a throwing dialog is a cancel, like dismissing it. The gate is
|
|
1342
|
+
// here (the user gesture), NOT in #applyOps: that applier is shared with the
|
|
1343
|
+
// server-pushed reactive:js stream action, which must NEVER prompt.
|
|
1344
|
+
Promise.resolve()
|
|
1345
|
+
.then(() => confirmResolver(message))
|
|
1346
|
+
.catch(() => false)
|
|
1347
|
+
.then((ok) => {
|
|
1348
|
+
if (ok) this.#applyOps(this.#parseOps(ops))
|
|
1349
|
+
})
|
|
1276
1350
|
}
|
|
1277
1351
|
|
|
1278
1352
|
// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
|
|
@@ -1305,6 +1379,16 @@ export default class extends Controller {
|
|
|
1305
1379
|
// changed = that output's name — the reducer must be convergent (see
|
|
1306
1380
|
// compute.js) so the change guard settles the chain.
|
|
1307
1381
|
recompute(event) {
|
|
1382
|
+
// Issue #183 — single-pass write set: an `input` event this method dispatched
|
|
1383
|
+
// for its OWN output writes is self-marked. Re-running the reducer on it would
|
|
1384
|
+
// re-enter from a partially-written DOM (the old mid-loop-dispatch corruption
|
|
1385
|
+
// class). Skip the reducer for our own event — but ONLY ours: the marker lives
|
|
1386
|
+
// in a per-instance WeakSet, so a genuinely different root's compute event (or
|
|
1387
|
+
// a real user edit) is never swallowed. The event still bubbled and fired every
|
|
1388
|
+
// OTHER listener (dirty tracking, show bindings, sibling roots) before reaching
|
|
1389
|
+
// here; we simply don't recompute a second time from our own write.
|
|
1390
|
+
if (event && this.#computeSelfDispatched.has(event)) return
|
|
1391
|
+
|
|
1308
1392
|
// Inputs may be a JSON ARRAY of names (array form — every input coerced
|
|
1309
1393
|
// through Number, the shipped behavior) or a JSON OBJECT of name→type (hash
|
|
1310
1394
|
// form, issue #104 — :number coerced, :string read raw). #parseComputeInputs
|
|
@@ -1330,12 +1414,19 @@ export default class extends Controller {
|
|
|
1330
1414
|
// The memo is per-CALL only: an output write dispatches `input` (issue #76),
|
|
1331
1415
|
// re-entering recompute, which correctly rebuilds a fresh map (a morph may
|
|
1332
1416
|
// have replaced the nodes) — it is NEVER stored on the instance.
|
|
1417
|
+
// Scope (issue #183, mirroring #showFieldValue): a bare compute name `cash`
|
|
1418
|
+
// under `data-reactive-scope="order"` resolves as `[name="order[cash]"]`. A
|
|
1419
|
+
// name already carrying a bracket (a raw wire name the author passed) is used
|
|
1420
|
+
// verbatim — so bracketed literals pass through unscoped.
|
|
1421
|
+
const scope = this.element.getAttribute?.("data-reactive-scope") || null
|
|
1422
|
+
const scoped = (name) => (scope && !name.includes("[") ? `${scope}[${name}]` : name)
|
|
1423
|
+
|
|
1333
1424
|
const owns = this.#ownershipFilter()
|
|
1334
1425
|
const byName = new Map()
|
|
1335
1426
|
const ownedField = (name) => {
|
|
1336
1427
|
if (byName.has(name)) return byName.get(name)
|
|
1337
1428
|
let found = null
|
|
1338
|
-
for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
|
|
1429
|
+
for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
|
|
1339
1430
|
if (owns(el)) {
|
|
1340
1431
|
found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
|
|
1341
1432
|
break
|
|
@@ -1381,36 +1472,61 @@ export default class extends Controller {
|
|
|
1381
1472
|
|
|
1382
1473
|
// meta.changed stays on #changedComputeField (its own #ownsField check over
|
|
1383
1474
|
// the raw event target) — NOT this resolver. The issue-#15 nested-rejection
|
|
1384
|
-
// test depends on that path being unchanged.
|
|
1385
|
-
|
|
1475
|
+
// test depends on that path being unchanged. ONE run, from the ONE pre-write
|
|
1476
|
+
// snapshot above (issue #183): its result drives the whole single-pass write.
|
|
1477
|
+
const result = reduce(values, { changed: this.#changedComputeField(event, inputs, scope) }) || {}
|
|
1478
|
+
|
|
1479
|
+
// Issue #183 — SINGLE-PASS WRITE SET. Three ordered phases, so declared output
|
|
1480
|
+
// order stops being semantics and a wrong order can no longer corrupt values:
|
|
1481
|
+
//
|
|
1482
|
+
// 1. BATCH the field writes from the ONE result. Each output name in the
|
|
1483
|
+
// allowlist (outputs:) whose owned field's value actually changes is
|
|
1484
|
+
// written now (change-guarded) and remembered — but NO `input` event is
|
|
1485
|
+
// dispatched yet, so nothing re-enters mid-batch.
|
|
1486
|
+
// 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
|
|
1487
|
+
// presence (issue #183 change #4 — a text node no longer needs its name in
|
|
1488
|
+
// outputs:), then the cross-root mirror: ids (issue #159).
|
|
1489
|
+
// 3. DISPATCH a self-marked `input` on each changed field. The marker (a
|
|
1490
|
+
// per-instance WeakSet) makes recompute skip re-running the reducer for our
|
|
1491
|
+
// own write, while the event still fires every OTHER listener (chained
|
|
1492
|
+
// repaint, dirty tracking, show bindings, sibling roots).
|
|
1493
|
+
const changedFields = []
|
|
1386
1494
|
for (const name of outputs) {
|
|
1387
1495
|
if (!(name in result)) continue
|
|
1388
1496
|
const field = ownedField(name)
|
|
1389
|
-
//
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
}
|
|
1497
|
+
if (!field) continue // a non-field output paints as a text sink in phase 2
|
|
1498
|
+
if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
|
|
1499
|
+
field.value = result[name]
|
|
1500
|
+
changedFields.push(field)
|
|
1409
1501
|
}
|
|
1410
1502
|
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1503
|
+
// Phase 2 — text sinks declare themselves (issue #183 change #4): every result
|
|
1504
|
+
// key paints into any owned [data-reactive-text="<name>"] node by PRESENCE,
|
|
1505
|
+
// regardless of outputs: membership. Runs from settled field values. A null/
|
|
1506
|
+
// undefined result value is SKIPPED (never stringified to "null"/"undefined") —
|
|
1507
|
+
// the same "no value this pass, don't paint" filter #applyComputeMirrors uses.
|
|
1508
|
+
for (const name of Object.keys(result)) {
|
|
1509
|
+
const value = result[name]
|
|
1510
|
+
if (value === undefined || value === null) continue
|
|
1511
|
+
this.#mirrorText(name, value)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
|
|
1515
|
+
// mirror keyed on a just-written output paints the settled value.
|
|
1413
1516
|
this.#applyComputeMirrors(result, ownedField)
|
|
1517
|
+
|
|
1518
|
+
// Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
|
|
1519
|
+
// do NOT fire `input` on a programmatic .value write (issue #76), so we do it
|
|
1520
|
+
// ourselves, matching the server's set_value + dispatch("input") contract.
|
|
1521
|
+
// Each event is SELF-MARKED so our own re-entry skips the reducer (guard at the
|
|
1522
|
+
// top of recompute) — but the event still bubbles and fires every other
|
|
1523
|
+
// listener. Dispatched AFTER all writes + paints, so a chained listener reads
|
|
1524
|
+
// SETTLED values, never a half-written DOM.
|
|
1525
|
+
for (const field of changedFields) {
|
|
1526
|
+
const inputEvent = new Event("input", { bubbles: true })
|
|
1527
|
+
this.#computeSelfDispatched.add(inputEvent)
|
|
1528
|
+
field.dispatchEvent(inputEvent)
|
|
1529
|
+
}
|
|
1414
1530
|
}
|
|
1415
1531
|
|
|
1416
1532
|
// Client-side list navigation (combobox keyboard nav, issue #72). Wired by
|
|
@@ -1517,11 +1633,26 @@ export default class extends Controller {
|
|
|
1517
1633
|
// named form control OWNED by this root (not a nested reactive root's, issue
|
|
1518
1634
|
// #15) AND its name is among the declared compute inputs; anything else
|
|
1519
1635
|
// (a direct call, an unowned/undeclared target) yields null.
|
|
1520
|
-
|
|
1636
|
+
//
|
|
1637
|
+
// Scope-aware (issue #184): under data-reactive-scope, the edited field's DOM
|
|
1638
|
+
// name is scoped (order[allowance]) while the declared inputs are BARE
|
|
1639
|
+
// (allowance). Strip the scope prefix off the DOM name before comparing, and
|
|
1640
|
+
// return the BARE name — so a reducer branching on `changed` sees the same
|
|
1641
|
+
// names it declared, scoped or not.
|
|
1642
|
+
#changedComputeField(event, inputs, scope) {
|
|
1521
1643
|
const target = event?.target
|
|
1522
1644
|
if (!target?.name || typeof target.closest !== "function") return null
|
|
1523
|
-
|
|
1524
|
-
|
|
1645
|
+
const bare = this.#unscopeName(target.name, scope)
|
|
1646
|
+
if (!inputs.includes(bare)) return null
|
|
1647
|
+
return this.#ownsField(target) ? bare : null
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// Strip a leading `scope[…]` wrapper off a DOM field name, returning the bare
|
|
1651
|
+
// inner name; a name that isn't wrapped in this scope passes through unchanged.
|
|
1652
|
+
#unscopeName(name, scope) {
|
|
1653
|
+
if (!scope) return name
|
|
1654
|
+
const prefix = `${scope}[`
|
|
1655
|
+
return name.startsWith(prefix) && name.endsWith("]") ? name.slice(prefix.length, -1) : name
|
|
1525
1656
|
}
|
|
1526
1657
|
|
|
1527
1658
|
// Write `value` into every owned [data-reactive-text="<name>"] node via
|
|
@@ -1590,7 +1721,7 @@ export default class extends Controller {
|
|
|
1590
1721
|
// Split out of dispatch so both the no-confirm fast path and the post-confirm
|
|
1591
1722
|
// microtask share one place (issue #55). `target` is captured up front because
|
|
1592
1723
|
// this can run in a later microtask, after event.target has been reset.
|
|
1593
|
-
#proceed(target, action, params, debounce, throttle, optimistic,
|
|
1724
|
+
#proceed(target, action, params, debounce, throttle, optimistic, busy) {
|
|
1594
1725
|
// Lifecycle veto point (issue #79): one cancelable reactive:before-dispatch
|
|
1595
1726
|
// per user gesture — post-preventDefault, post-confirm, PRE-debounce (and
|
|
1596
1727
|
// PRE-throttle, the same timing). event.preventDefault() skips the
|
|
@@ -1608,21 +1739,21 @@ export default class extends Controller {
|
|
|
1608
1739
|
// Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
|
|
1609
1740
|
// coalesce rapid events into ONE round trip after a quiet period, instead of
|
|
1610
1741
|
// one POST per keystroke (issue #17). A blur flushes a pending dispatch.
|
|
1611
|
-
// The optimistic hint (issue #98) and the
|
|
1742
|
+
// The optimistic hint (issue #98) and the busy state (issue #181) ride to
|
|
1612
1743
|
// the flush too, so they apply ONCE per enqueue — a debounced input must not
|
|
1613
1744
|
// flap toggle_class per keystroke, and its element must NOT be disabled
|
|
1614
1745
|
// during the quiet period (that would break typing). Both apply at ENQUEUE.
|
|
1615
1746
|
const ms = Number(debounce) || 0
|
|
1616
|
-
if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic,
|
|
1747
|
+
if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, busy)
|
|
1617
1748
|
|
|
1618
1749
|
// Throttled trigger (e.g. on(:track, event: "scroll", window: true,
|
|
1619
1750
|
// throttle: 250), issue #80): LEADING-EDGE rate limit — fire the first
|
|
1620
1751
|
// event immediately, drop the rest until the window elapses. debounce and
|
|
1621
1752
|
// throttle are mutually exclusive (the Ruby on() raises on both).
|
|
1622
1753
|
const throttleMs = Number(throttle) || 0
|
|
1623
|
-
if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic,
|
|
1754
|
+
if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, busy)
|
|
1624
1755
|
|
|
1625
|
-
return this.#enqueue(action, params, optimistic, target,
|
|
1756
|
+
return this.#enqueue(action, params, optimistic, target, busy)
|
|
1626
1757
|
}
|
|
1627
1758
|
|
|
1628
1759
|
// Apply the optimistic hint ONCE (recording its inverse) and chain the round
|
|
@@ -1631,29 +1762,55 @@ export default class extends Controller {
|
|
|
1631
1762
|
// #98). Applying here — the single flush/enqueue point every path funnels
|
|
1632
1763
|
// through — is what makes a hint apply once per enqueue, not per raw dispatch.
|
|
1633
1764
|
//
|
|
1634
|
-
// The
|
|
1765
|
+
// The busy state (issue #181) applies here too, for the same reason: enqueue
|
|
1635
1766
|
// is the moment the request is committed to the queue, so the always-on busy
|
|
1636
1767
|
// vocabulary (data-reactive-busy on the trigger + root, aria-busy via a pending
|
|
1637
|
-
// counter, busy_on scoping) and the
|
|
1768
|
+
// counter, busy_on scoping) and the busy hint (disable + class + text swap)
|
|
1638
1769
|
// cover the WHOLE pending window — queue wait included — not just the fetch. It
|
|
1639
1770
|
// returns a `settle` closure that #perform runs in its finally (success OR
|
|
1640
1771
|
// failure), guarded so a morph-replaced trigger is never clobbered.
|
|
1641
|
-
#enqueue(action, params, optimistic, target,
|
|
1772
|
+
#enqueue(action, params, optimistic, target, busy) {
|
|
1642
1773
|
const inverse = this.#applyOptimistic(optimistic, target)
|
|
1643
|
-
const settle = this.#
|
|
1644
|
-
|
|
1774
|
+
const settle = this.#applyBusy(action, target, busy)
|
|
1775
|
+
// Debug-only teaching aid (issue #181): if optimistic: { hide: true } is used
|
|
1776
|
+
// for instant-delete but the reply RE-RENDERS the element (bringing it back),
|
|
1777
|
+
// that hint was pointless — the developer likely wanted reply.remove. Capture
|
|
1778
|
+
// the hidden nodes now; the success path re-checks the OBSERVED DOM after the
|
|
1779
|
+
// morph (never inferred from the verb) and warns if any came back visible.
|
|
1780
|
+
const resurrect = this.#debugEnabled() ? this.#buildResurrectionCheck(optimistic, target) : null
|
|
1781
|
+
this.queue = (this.queue ?? Promise.resolve())
|
|
1782
|
+
.then(() => this.#perform(action, params, inverse, settle, resurrect))
|
|
1645
1783
|
return this.queue
|
|
1646
1784
|
}
|
|
1647
1785
|
|
|
1786
|
+
// Snapshot the elements an optimistic hide: targeted (the trigger, or the `to:`
|
|
1787
|
+
// selector) so the success path can detect a resurrection. Returns null unless
|
|
1788
|
+
// a hide: hint is present — nothing else can be "resurrected".
|
|
1789
|
+
#buildResurrectionCheck(optimistic, target) {
|
|
1790
|
+
if (!optimistic?.hide) return null
|
|
1791
|
+
const hidden = this.#hintTargets(optimistic, target)
|
|
1792
|
+
if (!hidden.length) return null
|
|
1793
|
+
return () => {
|
|
1794
|
+
const back = hidden.filter((el) => el.isConnected && !el.hidden)
|
|
1795
|
+
if (!back.length) return
|
|
1796
|
+
console.warn(
|
|
1797
|
+
"[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — " +
|
|
1798
|
+
"the element is visible again. For an instant delete, return reply.remove so the " +
|
|
1799
|
+
"server removes it; otherwise the hide only flashes.",
|
|
1800
|
+
back,
|
|
1801
|
+
)
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1648
1805
|
// Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
|
|
1649
1806
|
// Also flush immediately on blur so leaving the field never drops the last
|
|
1650
1807
|
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
1651
|
-
#debounceDispatch(target, ms, action, params, optimistic,
|
|
1808
|
+
#debounceDispatch(target, ms, action, params, optimistic, busy) {
|
|
1652
1809
|
this.#clearDebounce(target)
|
|
1653
1810
|
|
|
1654
1811
|
const flush = () => {
|
|
1655
1812
|
this.#clearDebounce(target)
|
|
1656
|
-
this.#enqueue(action, params, optimistic, target,
|
|
1813
|
+
this.#enqueue(action, params, optimistic, target, busy)
|
|
1657
1814
|
}
|
|
1658
1815
|
const timer = setTimeout(flush, ms)
|
|
1659
1816
|
target?.addEventListener?.("blur", flush, { once: true })
|
|
@@ -1681,7 +1838,7 @@ export default class extends Controller {
|
|
|
1681
1838
|
// are keyed on action + target, NOT target alone: window-bound scroll/resize
|
|
1682
1839
|
// events all share event.target === document, so two window-bound triggers
|
|
1683
1840
|
// on one component would otherwise collide on one timer.
|
|
1684
|
-
#throttleDispatch(target, ms, action, params, optimistic,
|
|
1841
|
+
#throttleDispatch(target, ms, action, params, optimistic, busy) {
|
|
1685
1842
|
const timers = this.#throttleTimers.get(target) ?? new Map()
|
|
1686
1843
|
if (timers.has(action)) return // inside the window — suppress
|
|
1687
1844
|
|
|
@@ -1691,7 +1848,7 @@ export default class extends Controller {
|
|
|
1691
1848
|
}, ms)
|
|
1692
1849
|
timers.set(action, timer)
|
|
1693
1850
|
this.#throttleTimers.set(target, timers)
|
|
1694
|
-
return this.#enqueue(action, params, optimistic, target,
|
|
1851
|
+
return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
|
|
1695
1852
|
}
|
|
1696
1853
|
|
|
1697
1854
|
// Clear every throttle suppression timer (used on disconnect, alongside
|
|
@@ -1852,7 +2009,7 @@ export default class extends Controller {
|
|
|
1852
2009
|
/* eslint-enable no-console */
|
|
1853
2010
|
}
|
|
1854
2011
|
|
|
1855
|
-
async #perform(action, params, inverse, settle) {
|
|
2012
|
+
async #perform(action, params, inverse, settle, resurrect) {
|
|
1856
2013
|
// Auto-collect named field values inside this component so a button-
|
|
1857
2014
|
// triggered action still receives sibling inputs (Livewire-style), plus any
|
|
1858
2015
|
// chosen file inputs in the SAME walk. Explicit params
|
|
@@ -2046,6 +2203,10 @@ export default class extends Controller {
|
|
|
2046
2203
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
2047
2204
|
// focused input + caret on unchanged nodes — see issue #28.
|
|
2048
2205
|
window.Turbo.renderStreamMessage(html)
|
|
2206
|
+
// Debug-only (issue #181): the morph may apply a microtask later, so check
|
|
2207
|
+
// the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
|
|
2208
|
+
// null — zero cost.
|
|
2209
|
+
if (resurrect) queueMicrotask(resurrect)
|
|
2049
2210
|
// A successful apply CLEARS any prior failure marker (issue #100), so
|
|
2050
2211
|
// error-driven CSS on the root (a red border, a shake) resets on recovery.
|
|
2051
2212
|
this.#clearError()
|
|
@@ -2192,6 +2353,54 @@ export default class extends Controller {
|
|
|
2192
2353
|
return (el) => this.#ownsField(el)
|
|
2193
2354
|
}
|
|
2194
2355
|
|
|
2356
|
+
// Resolve the effective confirm message (issue #179). A plain string is the
|
|
2357
|
+
// static #52 form (always shown). A confirmWhen JSON payload is the CONDITIONAL
|
|
2358
|
+
// form — evaluated over the SAME collected fields reactive_compute reads — and
|
|
2359
|
+
// returns the message ONLY when it fires, else null (proceed, no dialog):
|
|
2360
|
+
// { groups, message } — the reactive_show conditions fold (anyOfAllsMatches)
|
|
2361
|
+
// { predicate, message } — a registered fn (setConfirmPredicate) over the fields
|
|
2362
|
+
// A missing predicate warns and returns null (PROCEED without a dialog) — the
|
|
2363
|
+
// compute unknown-reducer posture. This is soft-validation UX; the endpoint's
|
|
2364
|
+
// authorize/default-deny is the real gate, so failing OPEN here never grants
|
|
2365
|
+
// anything the server wouldn't already allow.
|
|
2366
|
+
#effectiveConfirmMessage(confirm, confirmWhen) {
|
|
2367
|
+
if (confirm) return confirm
|
|
2368
|
+
if (!confirmWhen) return null
|
|
2369
|
+
|
|
2370
|
+
// Stimulus auto-parses a JSON-object -param value, so confirmWhen usually
|
|
2371
|
+
// arrives ALREADY parsed. Accept an object as-is; parse a string defensively
|
|
2372
|
+
// (a hand-built attr, or a non-Stimulus caller). A malformed string warns and
|
|
2373
|
+
// proceeds without a dialog (default-deny UX — the server is the real gate).
|
|
2374
|
+
let payload = confirmWhen
|
|
2375
|
+
if (typeof confirmWhen === "string") {
|
|
2376
|
+
try {
|
|
2377
|
+
payload = JSON.parse(confirmWhen)
|
|
2378
|
+
} catch {
|
|
2379
|
+
console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(confirmWhen)} — skipped`)
|
|
2380
|
+
return null
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (!payload || typeof payload !== "object") return null
|
|
2384
|
+
|
|
2385
|
+
const { fields } = this.#collectFields()
|
|
2386
|
+
const fieldValue = (name) => fields[name]
|
|
2387
|
+
|
|
2388
|
+
let fires
|
|
2389
|
+
if (typeof payload.predicate === "string") {
|
|
2390
|
+
const fn = confirmPredicate(payload.predicate)
|
|
2391
|
+
if (!fn) {
|
|
2392
|
+
console.warn(`[phlex-reactive] confirm predicate "${payload.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`)
|
|
2393
|
+
return null
|
|
2394
|
+
}
|
|
2395
|
+
fires = !!fn(fields)
|
|
2396
|
+
} else {
|
|
2397
|
+
// Declarative: the DNF groups fold, identical to reactive_show — matches → fire.
|
|
2398
|
+
fires = anyOfAllsMatches(payload.groups?.any, fieldValue) === true
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
return fires ? payload.message : null
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2195
2404
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
2196
2405
|
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
2197
2406
|
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
|
@@ -2541,6 +2750,14 @@ export default class extends Controller {
|
|
|
2541
2750
|
)
|
|
2542
2751
|
}
|
|
2543
2752
|
|
|
2753
|
+
// Whether this root opts into the connect-time compute seed (issue #199).
|
|
2754
|
+
// reactive_compute's root binding emits data-reactive-compute-seed="true"; a
|
|
2755
|
+
// root without a compute binding (or with the seed opted out) pays one
|
|
2756
|
+
// attribute read and never seeds. A quick read, evaluated once per connect.
|
|
2757
|
+
#computeSeedEnabled() {
|
|
2758
|
+
return this.element.getAttribute?.("data-reactive-compute-seed") === "true"
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2544
2761
|
// Whether a delegated input event came from the NAMED filter input (issue
|
|
2545
2762
|
// #163). Anything else — another field's keystroke, a target without
|
|
2546
2763
|
// matches() — skips the filter pass (the morph re-sync path bypasses this).
|
|
@@ -2609,6 +2826,15 @@ export default class extends Controller {
|
|
|
2609
2826
|
this.#boundSyncFilter = undefined
|
|
2610
2827
|
}
|
|
2611
2828
|
|
|
2829
|
+
// Remove the compute seed morph listener on disconnect (issue #199), so a
|
|
2830
|
+
// stray turbo:morph-element after the element leaves the DOM never re-seeds
|
|
2831
|
+
// against a detached root.
|
|
2832
|
+
#teardownComputeSeed() {
|
|
2833
|
+
if (!this.#boundSeedCompute) return
|
|
2834
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSeedCompute)
|
|
2835
|
+
this.#boundSeedCompute = undefined
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2612
2838
|
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
2613
2839
|
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
2614
2840
|
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|
|
@@ -2723,79 +2949,30 @@ export default class extends Controller {
|
|
|
2723
2949
|
return type === "checkbox" || type === "radio"
|
|
2724
2950
|
}
|
|
2725
2951
|
|
|
2726
|
-
// Apply the
|
|
2727
|
-
//
|
|
2728
|
-
//
|
|
2729
|
-
// the
|
|
2730
|
-
//
|
|
2731
|
-
// browser already flipped it). Returns null when there is nothing to do, so
|
|
2732
|
-
// the success/failure paths can cheaply skip.
|
|
2952
|
+
// Apply the OPTIMISTIC hint (issue #98) NOW and return its `undo` closure — the
|
|
2953
|
+
// exact ops to replay on FAILURE (optimistic reverts only when the round trip
|
|
2954
|
+
// fails; success leaves server truth or the deliberately-standing hint). It is
|
|
2955
|
+
// the same op vocabulary busy: uses (issue #181) via the one #applyHint engine;
|
|
2956
|
+
// the ONLY optimistic-specific op is checked: :keep (honorChecked = true).
|
|
2733
2957
|
#applyOptimistic(optimistic, trigger) {
|
|
2734
2958
|
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
|
-
|
|
2959
|
+
const undo = this.#applyHint(optimistic, trigger, true)
|
|
2775
2960
|
return undo.length ? undo : null
|
|
2776
2961
|
}
|
|
2777
2962
|
|
|
2778
|
-
// Replay the recorded
|
|
2779
|
-
//
|
|
2780
|
-
//
|
|
2781
|
-
//
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
2963
|
+
// Replay the recorded undo ops on failure (issue #98), guarded by isConnected:
|
|
2964
|
+
// a plain (non-morph) replace can detach this subtree before the failure lands,
|
|
2965
|
+
// and reverting a stale/detached node is pointless (it's gone) — so a
|
|
2966
|
+
// disconnected root skips the revert entirely. On success NOTHING calls this:
|
|
2967
|
+
// the server re-render overwrites the hint, or (reply.remove / streams-only)
|
|
2968
|
+
// the hint is deliberately left standing.
|
|
2784
2969
|
#revertOptimistic(inverse) {
|
|
2785
2970
|
if (!inverse) return
|
|
2786
2971
|
if (!this.element.isConnected) return
|
|
2787
2972
|
for (const undo of inverse) undo()
|
|
2788
2973
|
}
|
|
2789
2974
|
|
|
2790
|
-
//
|
|
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`
|
|
2975
|
+
// Apply the BUSY state for THIS enqueue (issue #181) and return a `settle`
|
|
2799
2976
|
// closure that undoes exactly this enqueue's contribution when the round trip
|
|
2800
2977
|
// finishes (success OR any failure). Everything is refcounted so overlapping
|
|
2801
2978
|
// enqueues never clobber: A's settle can't clear busy while B is still pending.
|
|
@@ -2806,21 +2983,119 @@ export default class extends Controller {
|
|
|
2806
2983
|
// space-separated, per-action refcounted set), aria-busy on the root (a
|
|
2807
2984
|
// pending counter), and data-reactive-busy on any busy_on element scoped
|
|
2808
2985
|
// to this action. Apps style a spinner with pure CSS and zero Ruby.
|
|
2809
|
-
// 2. The
|
|
2810
|
-
//
|
|
2811
|
-
//
|
|
2812
|
-
//
|
|
2813
|
-
|
|
2986
|
+
// 2. The busy HINT (only when busy: was declared): the SAME cosmetic op set
|
|
2987
|
+
// as optimistic: (class ops, hide/show, disable, text), applied through
|
|
2988
|
+
// the one #applyHint engine and reverted on SETTLE (not on failure). These
|
|
2989
|
+
// apply at ENQUEUE — never during a debounce quiet period — so a debounced
|
|
2990
|
+
// input is not disabled mid-typing. checked: is optimistic-only, so busy:
|
|
2991
|
+
// passes honorChecked = false (the Ruby on() already rejects it — this is
|
|
2992
|
+
// belt-and-braces).
|
|
2993
|
+
#applyBusy(action, trigger, busy) {
|
|
2814
2994
|
this.#markBusy(action, trigger)
|
|
2815
|
-
const
|
|
2995
|
+
const undo = busy ? this.#applyHint(busy, trigger, false) : []
|
|
2816
2996
|
|
|
2817
2997
|
let settled = false
|
|
2818
2998
|
return () => {
|
|
2819
2999
|
if (settled) return // one settle per enqueue, even if called twice
|
|
2820
3000
|
settled = true
|
|
2821
3001
|
this.#unmarkBusy(action, trigger)
|
|
2822
|
-
|
|
3002
|
+
// Busy reverts on SETTLE regardless of outcome, guarded per element.
|
|
3003
|
+
for (const op of undo) op()
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// The ONE pending-state hint engine (issue #181), shared by optimistic: (revert
|
|
3008
|
+
// on failure) and busy: (revert on settle) — they differ only in WHEN the
|
|
3009
|
+
// returned undo ops run, never in the ops themselves. Applies the hint's
|
|
3010
|
+
// cosmetic ops to their targets (the trigger by default, or a `to:` selector
|
|
3011
|
+
// scoped to the root) and returns an array of undo closures. Class ops and
|
|
3012
|
+
// hide/show use a DELTA inverse (undo only what THIS call changed, so it
|
|
3013
|
+
// composes across overlapping enqueues); disable/text use a REFCOUNTED snapshot
|
|
3014
|
+
// (the true pre-hint value survives an overlapping enqueue that would otherwise
|
|
3015
|
+
// capture the already-swapped label as the "original"). `honorChecked` gates
|
|
3016
|
+
// checked: :keep — an optimistic-only native-control revert.
|
|
3017
|
+
#applyHint(hint, trigger, honorChecked) {
|
|
3018
|
+
const undo = []
|
|
3019
|
+
for (const el of this.#hintTargets(hint, trigger)) {
|
|
3020
|
+
if (hint.add_class) {
|
|
3021
|
+
// Undo only the classes this op ACTUALLY added — a class already present
|
|
3022
|
+
// was not our change, so reverting it would strip a class the element
|
|
3023
|
+
// legitimately had. Capture the real delta now.
|
|
3024
|
+
const added = hint.add_class.filter((c) => !el.classList.contains(c))
|
|
3025
|
+
el.classList.add(...added)
|
|
3026
|
+
if (added.length) undo.push(() => el.classList.remove(...added))
|
|
3027
|
+
}
|
|
3028
|
+
if (hint.remove_class) {
|
|
3029
|
+
// Symmetric: undo only the classes actually removed.
|
|
3030
|
+
const removed = hint.remove_class.filter((c) => el.classList.contains(c))
|
|
3031
|
+
el.classList.remove(...removed)
|
|
3032
|
+
if (removed.length) undo.push(() => el.classList.add(...removed))
|
|
3033
|
+
}
|
|
3034
|
+
if (hint.toggle_class) {
|
|
3035
|
+
// toggle_class is its own inverse regardless of prior state.
|
|
3036
|
+
hint.toggle_class.forEach((c) => el.classList.toggle(c))
|
|
3037
|
+
undo.push(() => hint.toggle_class.forEach((c) => el.classList.toggle(c)))
|
|
3038
|
+
}
|
|
3039
|
+
if (hint.hide) {
|
|
3040
|
+
el.hidden = true
|
|
3041
|
+
undo.push(() => (el.hidden = false))
|
|
3042
|
+
}
|
|
3043
|
+
if (hint.show) {
|
|
3044
|
+
el.hidden = false
|
|
3045
|
+
undo.push(() => (el.hidden = true))
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// disable/text swap the TRIGGER (a `to:` retargets only the class/hide/show
|
|
3050
|
+
// ops above — disable/text are inherently trigger affordances). Refcounted so
|
|
3051
|
+
// overlapping enqueues restore correctly.
|
|
3052
|
+
if (trigger && (hint.disable || hint.text != null)) {
|
|
3053
|
+
undo.push(this.#applyTextDisable(hint, trigger))
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// checked: :keep — the native flip already happened on the trigger; record
|
|
3057
|
+
// the inverse (flip it back) so a revert restores the control's state.
|
|
3058
|
+
if (honorChecked && hint.checked === "keep" && trigger && "checked" in trigger) {
|
|
3059
|
+
const flipped = trigger.checked
|
|
3060
|
+
undo.push(() => (trigger.checked = !flipped))
|
|
2823
3061
|
}
|
|
3062
|
+
|
|
3063
|
+
return undo
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// The elements a hint's class/hide/show ops apply to: the `to:` selector
|
|
3067
|
+
// (resolved like an op target — "@root" is the root, a selector is scoped to
|
|
3068
|
+
// this root's owned matches) or, with no `to:`, the trigger itself.
|
|
3069
|
+
#hintTargets(hint, trigger) {
|
|
3070
|
+
if (hint.to == null) return trigger ? [trigger] : []
|
|
3071
|
+
return this.#opTargets({ to: hint.to })
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the
|
|
3075
|
+
// ORIGINAL once per trigger (refcounted so an overlapping enqueue never
|
|
3076
|
+
// snapshots the already-swapped "Saving…" as the original), and return the undo
|
|
3077
|
+
// closure. text swaps innerHTML (issue #181), NOT textContent: a composite
|
|
3078
|
+
// trigger like `<button><svg/> Save</button>` has child nodes, and
|
|
3079
|
+
// textContent = "Saving…" would DESTROY the icon; innerHTML preserves the
|
|
3080
|
+
// markup structure and restores it byte-for-byte.
|
|
3081
|
+
#applyTextDisable(hint, trigger) {
|
|
3082
|
+
const snap = this.#textDisableSnapshots.get(trigger)
|
|
3083
|
+
if (snap) {
|
|
3084
|
+
snap.count++
|
|
3085
|
+
} else {
|
|
3086
|
+
this.#textDisableSnapshots.set(trigger, {
|
|
3087
|
+
count: 1,
|
|
3088
|
+
disabled: trigger.disabled,
|
|
3089
|
+
html: trigger.innerHTML,
|
|
3090
|
+
hadText: hint.text != null,
|
|
3091
|
+
swappedTo: hint.text,
|
|
3092
|
+
})
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
if (hint.disable) trigger.disabled = true
|
|
3096
|
+
if (hint.text != null) trigger.innerHTML = hint.text
|
|
3097
|
+
|
|
3098
|
+
return () => this.#restoreTextDisable(trigger, hint)
|
|
2824
3099
|
}
|
|
2825
3100
|
|
|
2826
3101
|
// Layer 1 — the always-on busy markers. Trigger + root carry the action token;
|
|
@@ -2886,72 +3161,35 @@ export default class extends Controller {
|
|
|
2886
3161
|
)
|
|
2887
3162
|
}
|
|
2888
3163
|
|
|
2889
|
-
//
|
|
2890
|
-
//
|
|
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
|
|
3164
|
+
// Restore the trigger's disabled/innerHTML from its snapshot when the LAST
|
|
3165
|
+
// enqueue for that trigger settles (refcount → 0). GUARDED: skip a disconnected
|
|
2932
3166
|
// trigger (a plain replace detached it — the node is gone), and do NOT restore
|
|
2933
|
-
// the
|
|
2934
|
-
// server label — clobbering it with the old
|
|
2935
|
-
|
|
2936
|
-
|
|
3167
|
+
// the label if it no longer equals what we swapped IN (a morph rendered a new
|
|
3168
|
+
// server label — clobbering it with the old markup would fight server truth).
|
|
3169
|
+
// The comparison + restore both use innerHTML so a composite trigger (icon +
|
|
3170
|
+
// label) round-trips its full markup, not a flattened text run (issue #181).
|
|
3171
|
+
#restoreTextDisable(trigger, hint) {
|
|
3172
|
+
const snap = this.#textDisableSnapshots.get(trigger)
|
|
2937
3173
|
if (!snap) return
|
|
2938
3174
|
if (--snap.count > 0) return // another enqueue for this trigger is still pending
|
|
2939
|
-
this.#
|
|
3175
|
+
this.#textDisableSnapshots.delete(trigger)
|
|
2940
3176
|
|
|
2941
3177
|
if (!trigger.isConnected) return // detached — nothing to restore
|
|
2942
3178
|
|
|
2943
|
-
if (
|
|
2944
|
-
|
|
2945
|
-
// changed textContent means the server morph relabeled it — leave it.
|
|
2946
|
-
if (snap.hadText && trigger.textContent === loading.text) trigger.textContent = snap.text
|
|
3179
|
+
if (hint.disable) trigger.disabled = snap.disabled
|
|
3180
|
+
if (snap.hadText && trigger.innerHTML === snap.swappedTo) trigger.innerHTML = snap.html
|
|
2947
3181
|
}
|
|
2948
3182
|
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
//
|
|
2952
|
-
#
|
|
2953
|
-
|
|
2954
|
-
|
|
3183
|
+
// Deploy-overlap read shim (issue #181): a page still rendered by the PREVIOUS
|
|
3184
|
+
// gem emits the old data-reactive-loading-param, whose `class:` key is the busy
|
|
3185
|
+
// vocabulary's `add_class:`. Remap it so an in-flight legacy page keeps its
|
|
3186
|
+
// pending affordance through the one #applyHint engine. Returns null for the
|
|
3187
|
+
// common (no legacy param) case so the fast path is untouched. Drop this shim
|
|
3188
|
+
// one minor after #181 ships (no page can still carry the old attr by then).
|
|
3189
|
+
#legacyLoadingHint(loading) {
|
|
3190
|
+
if (!loading || typeof loading !== "object") return null
|
|
3191
|
+
const { class: cls, ...rest } = loading
|
|
3192
|
+
return cls == null ? loading : { ...rest, add_class: cls }
|
|
2955
3193
|
}
|
|
2956
3194
|
|
|
2957
3195
|
// The action path comes from a <meta> tag that is fixed for the page's life,
|