phlex-reactive 0.15.3 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/README.md +92 -12
  4. data/app/javascript/phlex/reactive/core.js +1 -0
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +129 -16
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/countdown.js +140 -0
  11. data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
  12. data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
  13. data/app/javascript/phlex/reactive/features/persist.js +10 -6
  14. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  18. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  19. data/app/javascript/phlex/reactive/runtime.js +255 -31
  20. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  21. data/lib/phlex/reactive/component/field_references.rb +130 -0
  22. data/lib/phlex/reactive/component/helpers.rb +72 -14
  23. data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
  24. data/lib/phlex/reactive/component/media_binding.rb +9 -1
  25. data/lib/phlex/reactive/component/media_pattern.rb +17 -17
  26. data/lib/phlex/reactive/component/selection.rb +24 -2
  27. data/lib/phlex/reactive/component/show_options.rb +91 -0
  28. data/lib/phlex/reactive/engine.rb +2 -2
  29. data/lib/phlex/reactive/js.rb +44 -2
  30. data/lib/phlex/reactive/response.rb +3 -4
  31. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  32. data/lib/phlex/reactive/show_conditions.rb +39 -6
  33. data/lib/phlex/reactive/streamable.rb +6 -4
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. metadata +8 -1
@@ -37,6 +37,7 @@ import * as hintsFeature from "phlex/reactive/features/hints"
37
37
  import * as deferFeature from "phlex/reactive/features/defer"
38
38
  import * as sortableFeature from "phlex/reactive/features/sortable"
39
39
  import * as presenceFeature from "phlex/reactive/features/presence"
40
+ import * as countdownFeature from "phlex/reactive/features/countdown"
40
41
 
41
42
  // phlex/reactive/runtime — the reactive controller WITHOUT its feature
42
43
  // modules (issue #275): the code both client entries share. It is not an
@@ -436,6 +437,26 @@ const IMAGE_STATE = "data-reactive-image-state"
436
437
  const IMAGE_HIDDEN = "data-reactive-image-hidden"
437
438
  let imageFallbackListeners = null
438
439
 
440
+ // --- A wrapper an op hides (issues #391, #392) -------------------------------
441
+ // preview_file's wrap: and reactive_image_fallback's wrap: hide the box around
442
+ // an img. The mark says the runtime set that `hidden`, so a show removes only
443
+ // a hide of its own, never one the author wrote. On the element, for the
444
+ // IMAGE_HIDDEN reason. Visibility only: a wrapper usually holds the img, so
445
+ // nothing here writes its text.
446
+ const WRAP_HIDDEN = "data-reactive-wrap-hidden"
447
+
448
+ function hideWrap(el) {
449
+ if (el.hasAttribute("hidden")) return
450
+ el.setAttribute("hidden", "")
451
+ el.setAttribute(WRAP_HIDDEN, "")
452
+ }
453
+
454
+ function showWrap(el) {
455
+ if (!el.hasAttribute(WRAP_HIDDEN)) return
456
+ el.removeAttribute("hidden")
457
+ el.removeAttribute(WRAP_HIDDEN)
458
+ }
459
+
439
460
  function imageFallback(img) {
440
461
  if (img?.localName !== "img") return null
441
462
  const raw = img.getAttribute(IMAGE_FALLBACK)
@@ -451,23 +472,33 @@ const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset
451
472
 
452
473
  // The node that shows the alt text: a selector resolved inside the img's own
453
474
  // root only (a document listener has no op scope).
454
- function imageAltNode(img, wire) {
455
- if (typeof wire.alt !== "string" || !wire.alt) return null
475
+ function imageRootNode(img, selector) {
476
+ if (typeof selector !== "string" || !selector) return null
456
477
  try {
457
- const node = img.closest('[data-controller~="reactive"]')?.querySelector(wire.alt)
458
- // Never a node that holds the img: its textContent would wipe the img.
459
- return node && !node.contains(img) ? node : null
478
+ return img.closest('[data-controller~="reactive"]')?.querySelector(selector) ?? null
460
479
  } catch {
461
480
  return null
462
481
  }
463
482
  }
464
483
 
484
+ function imageAltNode(img, wire) {
485
+ const node = imageRootNode(img, wire.alt)
486
+ // Never a node that holds the img: its textContent would wipe the img.
487
+ return node && !node.contains(img) ? node : null
488
+ }
489
+
465
490
  function setImageState(img, wire, value) {
466
491
  if (value === "error") {
467
492
  if (wire.hide && !img.hasAttribute("hidden")) {
468
493
  img.setAttribute("hidden", "")
469
494
  img.setAttribute(IMAGE_HIDDEN, "")
470
495
  }
496
+ // wrap: (issue #392) may hold the img: only its visibility changes.
497
+ if (wire.wrap) {
498
+ const wrap = imageRootNode(img, wire.wrap)
499
+ if (wrap) hideWrap(wrap)
500
+ else console.warn(`[phlex-reactive] reactive_image_fallback wrap: ${JSON.stringify(wire.wrap)} is not in the root — skipped`)
501
+ }
471
502
  const node = imageAltNode(img, wire)
472
503
  if (node) {
473
504
  node.textContent = img.getAttribute("alt") ?? ""
@@ -483,6 +514,8 @@ function resetImage(img, wire = imageFallback(img)) {
483
514
  if (!wire || img.getAttribute(IMAGE_STATE) == null) return
484
515
  if (img.getAttribute(IMAGE_STATE) === "error") {
485
516
  if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
517
+ const wrap = imageRootNode(img, wire.wrap)
518
+ if (wrap) showWrap(wrap)
486
519
  const node = imageAltNode(img, wire)
487
520
  if (node) node.hidden = true
488
521
  }
@@ -1247,6 +1280,13 @@ const CLIENT_OPS = Object.freeze({
1247
1280
  // its first focusable descendant (opened-menu → first menuitem).
1248
1281
  focus: (el) => el.focus?.(),
1249
1282
  focus_first: (el) => firstFocusable(el)?.focus?.(),
1283
+ // Issue #399: blur the focused element when it is the target or inside it
1284
+ // (Escape ends a menu's :focus-within); focus elsewhere is left alone.
1285
+ // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
1286
+ blur: (el) => {
1287
+ const active = el.ownerDocument?.activeElement ?? globalThis.document?.activeElement
1288
+ if (active && (active === el || el.contains?.(active))) active.blur?.()
1289
+ },
1250
1290
 
1251
1291
  // Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
1252
1292
  // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
@@ -1429,8 +1469,18 @@ function previewFile(input, args, resolveTargets) {
1429
1469
  // valid: true (issue #382): an input the app marked invalid (setCustomValidity,
1430
1470
  // required) previews as empty. validity.valid, not checkValidity(): no
1431
1471
  // `invalid` event fired into the app's own listeners.
1432
- const files = args.valid === true && input.validity?.valid === false ? [] : (input.files ?? [])
1433
- const file = [...files].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1472
+ const files = [...(args.valid === true && input.validity?.valid === false ? [] : (input.files ?? []))]
1473
+ const accepts = previewAccepts(input, args.accept)
1474
+ const listed = files.filter((f) => typeof f?.type === "string" && accepts(f))
1475
+ // Only an image is painted: a listed PDF clears the preview, unrefused.
1476
+ let file = listed.find((f) => f.type.startsWith("image/"))
1477
+ // A refused file (issue #391) clears like an empty pick and says why. The
1478
+ // input keeps its files and its validity: the page owns the reset. Size is
1479
+ // read BEFORE an object URL exists, so an over-size file never gets one.
1480
+ let reason = files.length > 0 && listed.length === 0 ? "type" : null
1481
+ if (file && Number.isInteger(args.max_bytes) && file.size > args.max_bytes) reason = "size"
1482
+ const refused = reason === "size" ? file : files[0]
1483
+ if (reason) file = null
1434
1484
  forgetPreview(img)
1435
1485
  if (file) {
1436
1486
  const url = URL.createObjectURL(file)
@@ -1439,8 +1489,40 @@ function previewFile(input, args, resolveTargets) {
1439
1489
  watchPreviews()
1440
1490
  } else img.removeAttribute("src")
1441
1491
  resetImage(img)
1442
- if (args.name == null) return
1443
- for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1492
+ if (args.wrap != null) {
1493
+ const wraps = resolveTargets({ ...args, to: args.wrap })
1494
+ if (wraps.length === 0) warnPreview(`wrap: ${JSON.stringify(args.wrap)} is not on the page`)
1495
+ for (const wrap of wraps) file ? showWrap(wrap) : hideWrap(wrap)
1496
+ }
1497
+ if (args.name != null) {
1498
+ for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1499
+ }
1500
+ if (reason) {
1501
+ input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail: { reason, file: refused } }))
1502
+ }
1503
+ }
1504
+
1505
+ // Which files the page takes: an image by default. accept: true reads the
1506
+ // input's own accept attribute; a string is that list for this op. A token is
1507
+ // an extension (".png"), a MIME type or a type/* wildcard, the browser's own
1508
+ // accept grammar. An absent or empty list keeps the image rule.
1509
+ function previewAccepts(input, accept) {
1510
+ const tokens = String((accept === true ? input.getAttribute("accept") : typeof accept === "string" && accept) || "")
1511
+ .toLowerCase()
1512
+ .split(",")
1513
+ .map((token) => token.trim())
1514
+ .filter(Boolean)
1515
+ if (tokens.length === 0) return (f) => f.type.startsWith("image/")
1516
+ return (f) => {
1517
+ const type = f.type.toLowerCase()
1518
+ return tokens.some((token) =>
1519
+ token[0] === "."
1520
+ ? String(f.name).toLowerCase().endsWith(token)
1521
+ : token.endsWith("/*")
1522
+ ? type.startsWith(token.slice(0, -1))
1523
+ : type === token,
1524
+ )
1525
+ }
1444
1526
  }
1445
1527
 
1446
1528
  function warnPreview(why) {
@@ -1939,6 +2021,30 @@ function deferNeeded(root) {
1939
2021
  )
1940
2022
  }
1941
2023
 
2024
+ // Issue #410: a tokenless CLIENT-ONLY root (a ClientBindings component: filter,
2025
+ // show, on_client) has no action of its own. It is transparent to server round
2026
+ // trips: a server trigger inside it dispatches on the nearest token-bearing root
2027
+ // above, and that root's #collectFields looks through it. A lazy or deferred
2028
+ // shell is tokenless by design and is NOT client-only.
2029
+ const AWAKE_ROOT = '[data-controller~="reactive"]'
2030
+ const ANY_ROOT = '[data-controller~="reactive"],[data-reactive-dormant~="reactive"]'
2031
+
2032
+ const clientOnlyRoot = (root) => root.getAttribute?.("data-reactive-token-value") == null && !deferNeeded(root)
2033
+
2034
+ // The first `selector` root at or above `from` that is not client-only, or
2035
+ // `stop` when the walk reaches it first, or null.
2036
+ function serverRoot(from, selector, stop) {
2037
+ let root = from?.closest?.(selector)
2038
+ while (root && root !== stop && clientOnlyRoot(root)) root = root.parentElement?.closest?.(selector)
2039
+ return root ?? null
2040
+ }
2041
+
2042
+ // Connected controllers by root element, so a client-only root can hand a
2043
+ // dispatch to the server root above it. A dispatch for a root that has not
2044
+ // connected yet (a dormant root being woken) waits here until it does.
2045
+ const rootControllers = new WeakMap()
2046
+ const heldForwards = new WeakMap()
2047
+
1942
2048
  // Dirty tracking with its navigate-away guard, and the paste-trigger gate:
1943
2049
  // the root or anything inside it carries one of the two markers. (A nested
1944
2050
  // root's marker loads the module for its parent too; the module then checks
@@ -1988,6 +2094,11 @@ function presenceNeeded(root) {
1988
2094
  return root.getAttribute?.("data-reactive-presence") != null
1989
2095
  }
1990
2096
 
2097
+ // Countdowns (issue #400): the root is, or holds, a reactive_countdown.
2098
+ function countdownNeeded(root) {
2099
+ return Boolean(root.matches?.("[data-reactive-countdown]") || root.querySelector?.("[data-reactive-countdown]"))
2100
+ }
2101
+
1991
2102
  const PRODUCTION_FEATURES = [
1992
2103
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1993
2104
  // writes what a request reads, so it gates.
@@ -2003,6 +2114,8 @@ const PRODUCTION_FEATURES = [
2003
2114
  // Its announce is a request of its own; nothing it does at connect is read
2004
2115
  // by another request: it never gates.
2005
2116
  ["presence", [presenceNeeded, null]],
2117
+ // A countdown writes only its own text and visibility: it never gates.
2118
+ ["countdown", [countdownNeeded, null]],
2006
2119
  [
2007
2120
  // Stream effects and dismissing flashes are document-level: the module
2008
2121
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -2166,7 +2279,7 @@ function featureTimeoutMs() {
2166
2279
  export function reactiveFeatureNames() {
2167
2280
  return __SPLIT__
2168
2281
  ? [...FEATURES.keys()]
2169
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
2282
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
2170
2283
  }
2171
2284
 
2172
2285
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -2190,6 +2303,7 @@ export function __loadReactiveFeatureForTest(name) {
2190
2303
  compute: computeFeature,
2191
2304
  sortable: sortableFeature,
2192
2305
  presence: presenceFeature,
2306
+ countdown: countdownFeature,
2193
2307
  effects: effectsFeature,
2194
2308
  hints: hintsFeature,
2195
2309
  devtools: devtoolsFeature,
@@ -2244,6 +2358,7 @@ const BINDINGS = 8
2244
2358
  const COMPUTE = 16
2245
2359
  const SORTABLE = 32
2246
2360
  const PRESENCE = 64
2361
+ const COUNTDOWN = 128
2247
2362
 
2248
2363
  // The default entry's features are part of this file: install them now, as
2249
2364
  // the opt-in entry does when one arrives.
@@ -2269,6 +2384,10 @@ const WINDOW_EARLY_TTL_MS = 1500
2269
2384
  // which a replay built from its trigger element's document carries).
2270
2385
  const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
2271
2386
 
2387
+ // Issue #393: the key on()'s dispatch debounce uses in #debounceTimers. An
2388
+ // on_client record keys on its JSON, which starts with "{", so never this.
2389
+ const DISPATCH_WAIT_KEY = "dispatch"
2390
+
2272
2391
  function earlyState() {
2273
2392
  globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
2274
2393
  return globalThis[EARLY_KEY]
@@ -2425,8 +2544,8 @@ export default class extends Controller {
2425
2544
  }
2426
2545
 
2427
2546
  #tokenCache // freshest token, threaded synchronously across queued requests
2428
- #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
2429
- #throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
2547
+ #debounceTimers = new Map() // trigger element -> Map(key -> { timer, flush }) pending run
2548
+ #throttleTimers = new Map() // trigger element -> Map(action or record key -> suppression timer)
2430
2549
  #actionPathCache // page-stable action path, resolved once per controller
2431
2550
  #timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
2432
2551
  #tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
@@ -2465,7 +2584,7 @@ export default class extends Controller {
2465
2584
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
2466
2585
  #featureEpoch = 0
2467
2586
  #featureHandle // the `core` handle features receive, built on first use
2468
- // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
2587
+ // The default entry (issue #305): one bit per feature, PERSIST … COUNTDOWN —
2469
2588
  // asked for (its marker read true) and connected on this connection.
2470
2589
  #featuresAsked = 0
2471
2590
  #featuresOn = 0
@@ -2479,6 +2598,7 @@ export default class extends Controller {
2479
2598
  // queueing them NOW — a connect-time seed below (a compute output write)
2480
2599
  // dispatches real input events. The queue itself drains at the very end.
2481
2600
  earlyState().connected.add(this.element)
2601
+ rootControllers.set(this.element, this)
2482
2602
 
2483
2603
  // Root-id guard (issue #48). The token round trip assumes the reactive root
2484
2604
  // element's id == component.id: the server targets component.id and the client
@@ -2568,6 +2688,7 @@ export default class extends Controller {
2568
2688
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
2569
2689
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
2570
2690
  this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
2691
+ this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
2571
2692
  }
2572
2693
  }
2573
2694
 
@@ -2606,6 +2727,7 @@ export default class extends Controller {
2606
2727
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
2607
2728
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2608
2729
  if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
2730
+ if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
2609
2731
  }
2610
2732
  }
2611
2733
 
@@ -2801,6 +2923,8 @@ export default class extends Controller {
2801
2923
  served: serverText,
2802
2924
  // Copy one <img> onto another (issue #373): the reactive_media binding.
2803
2925
  copyMedia,
2926
+ // set_value's write (issue #374): reactive_show clear: (issue #394).
2927
+ setValue: setFieldValue,
2804
2928
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2805
2929
  // (a synchronous throw is a cancel), never a rejection out.
2806
2930
  confirm: (message, context) =>
@@ -2900,6 +3024,14 @@ export default class extends Controller {
2900
3024
  )
2901
3025
  }
2902
3026
  this.#drainEarly()
3027
+ // Issue #410: dispatches a client-only root inside handed over before this
3028
+ // root connected (a dormant root woken by that very trigger).
3029
+ // currentTarget is null once propagation ended: pin the trigger back on.
3030
+ for (const [event, target] of heldForwards.get(this.element) ?? []) {
3031
+ if (event.currentTarget !== target) Object.defineProperty(event, "currentTarget", { value: target, configurable: true })
3032
+ this.dispatch(event)
3033
+ }
3034
+ heldForwards.delete(this.element)
2903
3035
  }
2904
3036
 
2905
3037
  // The trigger elements whose :once descriptor THIS connection replayed —
@@ -3000,6 +3132,7 @@ export default class extends Controller {
3000
3132
  }
3001
3133
  // Early triggers (issue #273): a disconnected root records again.
3002
3134
  earlyState().connected.delete(this.element)
3135
+ if (rootControllers.get(this.element) === this) rootControllers.delete(this.element)
3003
3136
  this.element.removeAttribute?.("data-reactive-connected")
3004
3137
  // A spent :once replay is remembered per element so a morph REPLY (root
3005
3138
  // still connected) cannot re-arm it. A disconnect drops Stimulus's own
@@ -3017,6 +3150,12 @@ export default class extends Controller {
3017
3150
  // a per-controller promise makes each dispatch wait for the previous one, so
3018
3151
  // it always uses the freshest token.
3019
3152
  dispatch(event) {
3153
+ // Issue #410: a client-only root has no action of its own — a server
3154
+ // trigger inside it belongs to the token-bearing root above. FIRST, so that
3155
+ // root runs every check below with its own element.
3156
+ if (event.params?.action && event.params.action !== LAZY_MATERIALIZE_ACTION && this.#clientOnly()) {
3157
+ return this.#forwardToServerRoot(event)
3158
+ }
3020
3159
  // A shortcut (issue #357) that is the other platform's twin, or pressed
3021
3160
  // in a field the user is typing in, is a complete no-op: the native
3022
3161
  // default (text undo) proceeds and nothing is announced. Checked FIRST: a
@@ -3181,6 +3320,37 @@ export default class extends Controller {
3181
3320
  }
3182
3321
 
3183
3322
 
3323
+ // A tokenless client-only root (issue #410). tokenValue is the Stimulus view
3324
+ // of data-reactive-token-value; the attribute check also rules out a shell.
3325
+ #clientOnly() {
3326
+ return !this.tokenValue && clientOnlyRoot(this.element)
3327
+ }
3328
+
3329
+ // Hand a server trigger to the nearest token-bearing root above (issue #410).
3330
+ // A dormant one is woken (as early.js wakes a root on its own trigger) and
3331
+ // runs the dispatch when it connects. With no such root nothing is sent.
3332
+ #forwardToServerRoot(event) {
3333
+ const root = serverRoot(this.element.parentElement, ANY_ROOT)
3334
+ if (!root) {
3335
+ if (this.element.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]'))
3336
+ console.warn(`[phlex-reactive] "${event.params.action}": no token on this root or above, nothing sent`)
3337
+ return
3338
+ }
3339
+ const controller = rootControllers.get(root)
3340
+ if (controller) return controller.dispatch(event)
3341
+ // Held past this event's dispatch: cancel its native default NOW, by
3342
+ // dispatch()'s own rule, or a submit/link would act before the request.
3343
+ const { shortcut, window: windowBound, optimistic } = event.params
3344
+ const target = event.currentTarget ?? event.target
3345
+ if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
3346
+ heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
3347
+ const data = root.dataset
3348
+ if (data.reactiveDormant) {
3349
+ data.controller = `${data.controller ?? ""} ${data.reactiveDormant}`.trim()
3350
+ delete data.reactiveDormant
3351
+ }
3352
+ }
3353
+
3184
3354
  // CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling
3185
3355
  // of dispatch(). Wired by on_client: applies the declared op chain
3186
3356
  // (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,
@@ -3233,13 +3403,21 @@ export default class extends Controller {
3233
3403
  if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
3234
3404
  continue
3235
3405
  if (onceBindingSpent(this, event, record)) continue
3236
- this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
3406
+ // A legacy attr reads its flags element-wide — but never a mix-ed on()'s
3407
+ // debounce/throttle params (issue #393): those belong to the dispatch.
3408
+ this.#runBinding(
3409
+ record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
3410
+ event,
3411
+ trigger,
3412
+ records.indexOf(record),
3413
+ )
3237
3414
  }
3238
3415
  }
3239
3416
 
3240
3417
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3241
3418
  // confirm gate, then its ops — every flag read from the binding itself.
3242
- #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
3419
+ #runBinding(record, event, trigger, ordinal) {
3420
+ const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
3243
3421
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3244
3422
  // outside: trigger is a COMPLETE no-op for events inside this root, before
3245
3423
  // preventDefault and before any op runs.
@@ -3262,7 +3440,7 @@ export default class extends Controller {
3262
3440
  // which must NEVER prompt. The same confirmResolver gate on(:action,
3263
3441
  // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
3264
3442
  const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
3265
- const apply = () => this.#applyOps(this.#parseOps(ops))
3443
+ const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
3266
3444
  if (!message) return apply()
3267
3445
  if (message instanceof Promise) {
3268
3446
  return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
@@ -3270,6 +3448,20 @@ export default class extends Controller {
3270
3448
  this.#confirmThen(message, trigger, apply)
3271
3449
  }
3272
3450
 
3451
+ // Issue #393: an on_client record's debounce:/throttle: — on()'s timers
3452
+ // (#debounce trailing, #throttle leading), keyed on the trigger + the
3453
+ // record's ordinal + JSON (two identical mix-ed records keep their own
3454
+ // timers), so disconnect clears them with the dispatch ones. The wait sits
3455
+ // after the outside guard, preventDefault and confirm, as on()'s does.
3456
+ #waitedOps(record, ordinal, trigger, run) {
3457
+ const debounce = Number(record.debounce) || 0
3458
+ const throttle = Number(record.throttle) || 0
3459
+ if (debounce <= 0 && throttle <= 0) return run
3460
+ const key = JSON.stringify([ordinal, record])
3461
+ if (debounce > 0) return () => this.#debounce(trigger, key, debounce, run)
3462
+ return () => this.#throttle(trigger, key, throttle, run)
3463
+ }
3464
+
3273
3465
  // Client-side compute (issue #104) is the compute feature's (issue #275,
3274
3466
  // features/compute.js). This is the method its descriptor names
3275
3467
  // (input->reactive#recompute), so it stays. While the module is on its way
@@ -3554,30 +3746,45 @@ export default class extends Controller {
3554
3746
  // Also flush immediately on blur so leaving the field never drops the last
3555
3747
  // edit (a long debounce shouldn't swallow a value the user tabbed away from).
3556
3748
  #debounceDispatch(target, ms, action, params, optimistic, busy) {
3557
- this.#clearDebounce(target)
3749
+ this.#debounce(target, DISPATCH_WAIT_KEY, ms, () => this.#enqueue(action, params, optimistic, target, busy))
3750
+ }
3751
+
3752
+ // The one trailing-edge timer on() and on_client (issue #393) share. Timers
3753
+ // are keyed on target + key: on()'s dispatch uses DISPATCH_WAIT_KEY, an
3754
+ // on_client record its ordinal + JSON (it starts with "["), so an on() debounce and an
3755
+ // on_client debounce on one input never cancel each other. A window-bound
3756
+ // trigger gets no blur flush: a window blur is a tab switch, not an edit.
3757
+ #debounce(target, key, ms, run) {
3758
+ this.#clearDebounce(target, key)
3558
3759
 
3559
3760
  const flush = () => {
3560
- this.#clearDebounce(target)
3561
- this.#enqueue(action, params, optimistic, target, busy)
3761
+ this.#clearDebounce(target, key)
3762
+ run()
3562
3763
  }
3563
3764
  const timer = setTimeout(flush, ms)
3564
- target?.addEventListener?.("blur", flush, { once: true })
3565
- this.#debounceTimers.set(target, { timer, flush })
3765
+ if (!isWindow(target)) target?.addEventListener?.("blur", flush, { once: true })
3766
+ let pending = this.#debounceTimers.get(target)
3767
+ if (!pending) this.#debounceTimers.set(target, (pending = new Map()))
3768
+ pending.set(key, { timer, flush })
3566
3769
  }
3567
3770
 
3568
- #clearDebounce(target) {
3569
- const pending = this.#debounceTimers.get(target)
3771
+ #clearDebounce(target, key) {
3772
+ const byKey = this.#debounceTimers.get(target)
3773
+ const pending = byKey?.get(key)
3570
3774
  if (!pending) return
3571
3775
  clearTimeout(pending.timer)
3572
3776
  target?.removeEventListener?.("blur", pending.flush)
3573
- this.#debounceTimers.delete(target)
3777
+ byKey.delete(key)
3778
+ if (byKey.size === 0) this.#debounceTimers.delete(target)
3574
3779
  }
3575
3780
 
3576
3781
  // Clear every pending debounce timer (used on disconnect). Reuses
3577
3782
  // #clearDebounce so all timer/listener teardown stays in one place. Snapshot
3578
- // the keys first — #clearDebounce mutates the map as it goes.
3783
+ // the entries first — #clearDebounce mutates the maps as it goes.
3579
3784
  #clearAllDebounces() {
3580
- for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
3785
+ for (const [target, byKey] of [...this.#debounceTimers]) {
3786
+ for (const key of [...byKey.keys()]) this.#clearDebounce(target, key)
3787
+ }
3581
3788
  }
3582
3789
 
3583
3790
  // Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
@@ -3587,16 +3794,22 @@ export default class extends Controller {
3587
3794
  // events all share event.target === document, so two window-bound triggers
3588
3795
  // on one component would otherwise collide on one timer.
3589
3796
  #throttleDispatch(target, ms, action, params, optimistic, busy) {
3797
+ return this.#throttle(target, action, ms, () => this.#enqueue(action, params, optimistic, target, busy))
3798
+ }
3799
+
3800
+ // The leading-edge window on() and on_client (issue #393) share; an
3801
+ // on_client record keys on its ordinal + JSON, which no action name can equal.
3802
+ #throttle(target, key, ms, run) {
3590
3803
  const timers = this.#throttleTimers.get(target) ?? new Map()
3591
- if (timers.has(action)) return // inside the window — suppress
3804
+ if (timers.has(key)) return // inside the window — suppress
3592
3805
 
3593
3806
  const timer = setTimeout(() => {
3594
- timers.delete(action)
3807
+ timers.delete(key)
3595
3808
  if (timers.size === 0) this.#throttleTimers.delete(target)
3596
3809
  }, ms)
3597
- timers.set(action, timer)
3810
+ timers.set(key, timer)
3598
3811
  this.#throttleTimers.set(target, timers)
3599
- return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
3812
+ return run() // leading edge: fire NOW
3600
3813
  }
3601
3814
 
3602
3815
  // Clear every throttle suppression timer (used on disconnect, alongside
@@ -4076,6 +4289,17 @@ export default class extends Controller {
4076
4289
  return (el) => this.#ownsField(el)
4077
4290
  }
4078
4291
 
4292
+ // #ownershipFilter for SERVER params (issue #410): nested client-only roots
4293
+ // are transparent, so a control under one is this root's when the first
4294
+ // token-bearing root above it is this.element. A nested token-bearing root
4295
+ // keeps its own fields (issue #15). Bindings, computes and listnav keep the
4296
+ // strict nearest-root #ownershipFilter.
4297
+ #serverFieldFilter() {
4298
+ const nested = this.element.querySelectorAll(AWAKE_ROOT)
4299
+ if (nested.length === 0 || [...nested].every(clientOnlyRoot)) return () => true
4300
+ return (el) => serverRoot(el, AWAKE_ROOT, this.element) === this.element
4301
+ }
4302
+
4079
4303
  // One walk over THIS root's named controls (not a nested reactive root's),
4080
4304
  // returning both the scalar `fields` and any chosen `files`. The ownership
4081
4305
  // predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
@@ -4087,7 +4311,7 @@ export default class extends Controller {
4087
4311
  #collectFields() {
4088
4312
  const fields = {}
4089
4313
  const files = []
4090
- const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
4314
+ const owns = this.#serverFieldFilter() // compute ONCE per dispatch (issue #117)
4091
4315
  const controls = []
4092
4316
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
4093
4317
  if (!owns(field)) return
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module Component
8
+ # A server deadline counted down on the client (issue #400).
9
+ #
10
+ # span(**reactive_countdown(until: deadline.iso8601, waiting: "#wait", done: "#done")) { "2:00" }
11
+ # div(id: "wait") { "Waiting" }
12
+ # div(id: "done", hidden: true) { "Finished" }
13
+ #
14
+ # The client paints the time left from until: and Date.now at connect and
15
+ # once a second ("1:30": unpadded minutes, a colon, padded seconds — the
16
+ # only format, :ms), as textContent only. A tab that wakes late jumps to
17
+ # the real time left. At zero, or with until: already past on connect, the
18
+ # text is "0:00", data-reactive-countdown-state is "done", waiting: hides,
19
+ # done: shows (CSS selectors inside the root) and the element dispatches
20
+ # reactive:countdown-done once; the timer stops. A morph that keeps until:
21
+ # does not dispatch again; one that changes it counts down again. No
22
+ # request and no frame reload: the event is the hook.
23
+ #
24
+ # The countdown's content is its text, so waiting: and done: name nodes
25
+ # beside it, never inside it: a reactive root that is itself the
26
+ # countdown has nowhere to put them.
27
+ #
28
+ # The server renders the initial text and visibility, so the page reads
29
+ # with JavaScript off. until: is an ISO8601 date-time WITH a zone (Z or an
30
+ # offset), or a Time, which is written as one: a zoneless string would
31
+ # read as the browser's local time.
32
+ module CountdownBinding
33
+ ISO8601_WITH_ZONE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
34
+
35
+ # `until` is a keyword: the value is read off the binding.
36
+ def reactive_countdown(until:, format: :ms, waiting: nil, done: nil, **attrs)
37
+ wire = { "until" => countdown_until!(binding.local_variable_get(:until)), "format" => countdown_format!(format) }
38
+ wire["waiting"] = countdown_selector!(:waiting, waiting) unless waiting.nil?
39
+ wire["done"] = countdown_selector!(:done, done) unless done.nil?
40
+ mix({ data: { reactive_countdown: wire.to_json } }, attrs)
41
+ end
42
+
43
+ private
44
+
45
+ def countdown_until!(value)
46
+ value = value.utc.iso8601(value.subsec.zero? ? 0 : 3) if value.is_a?(Time) || value.respond_to?(:time_zone)
47
+ return value if countdown_iso8601?(value)
48
+
49
+ raise ArgumentError, "reactive_countdown until: must be an ISO8601 date-time with a zone " \
50
+ "(e.g. \"2026-10-14T12:02:00Z\") or a Time, got #{value.inspect}"
51
+ end
52
+
53
+ # The shape (a zone is required), then a real date (no month 13).
54
+ def countdown_iso8601?(value)
55
+ value.is_a?(String) && value.match?(ISO8601_WITH_ZONE) && Time.iso8601(value) && true
56
+ rescue ::ArgumentError
57
+ false
58
+ end
59
+
60
+ def countdown_format!(format)
61
+ return "ms" if format == :ms
62
+
63
+ raise ArgumentError, "reactive_countdown format: must be :ms (the only format), got #{format.inspect}"
64
+ end
65
+
66
+ # A selector the client passes to querySelector inside the root.
67
+ def countdown_selector!(name, selector)
68
+ if selector == JS::ROOT_SENTINEL
69
+ raise ArgumentError,
70
+ "reactive_countdown #{name}: #{selector} names the whole root — pass the selector of a node inside it"
71
+ end
72
+ return selector if selector.is_a?(String) && !selector.strip.empty?
73
+
74
+ raise ArgumentError,
75
+ "reactive_countdown #{name}: must be a CSS selector (e.g. \"##{name}\"), got #{selector.inspect}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end