phlex-reactive 0.15.4 → 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.
@@ -2021,6 +2021,30 @@ function deferNeeded(root) {
2021
2021
  )
2022
2022
  }
2023
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
+
2024
2048
  // Dirty tracking with its navigate-away guard, and the paste-trigger gate:
2025
2049
  // the root or anything inside it carries one of the two markers. (A nested
2026
2050
  // root's marker loads the module for its parent too; the module then checks
@@ -2574,6 +2598,7 @@ export default class extends Controller {
2574
2598
  // queueing them NOW — a connect-time seed below (a compute output write)
2575
2599
  // dispatches real input events. The queue itself drains at the very end.
2576
2600
  earlyState().connected.add(this.element)
2601
+ rootControllers.set(this.element, this)
2577
2602
 
2578
2603
  // Root-id guard (issue #48). The token round trip assumes the reactive root
2579
2604
  // element's id == component.id: the server targets component.id and the client
@@ -2999,6 +3024,14 @@ export default class extends Controller {
2999
3024
  )
3000
3025
  }
3001
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)
3002
3035
  }
3003
3036
 
3004
3037
  // The trigger elements whose :once descriptor THIS connection replayed —
@@ -3099,6 +3132,7 @@ export default class extends Controller {
3099
3132
  }
3100
3133
  // Early triggers (issue #273): a disconnected root records again.
3101
3134
  earlyState().connected.delete(this.element)
3135
+ if (rootControllers.get(this.element) === this) rootControllers.delete(this.element)
3102
3136
  this.element.removeAttribute?.("data-reactive-connected")
3103
3137
  // A spent :once replay is remembered per element so a morph REPLY (root
3104
3138
  // still connected) cannot re-arm it. A disconnect drops Stimulus's own
@@ -3116,6 +3150,12 @@ export default class extends Controller {
3116
3150
  // a per-controller promise makes each dispatch wait for the previous one, so
3117
3151
  // it always uses the freshest token.
3118
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
+ }
3119
3159
  // A shortcut (issue #357) that is the other platform's twin, or pressed
3120
3160
  // in a field the user is typing in, is a complete no-op: the native
3121
3161
  // default (text undo) proceeds and nothing is announced. Checked FIRST: a
@@ -3280,6 +3320,37 @@ export default class extends Controller {
3280
3320
  }
3281
3321
 
3282
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
+
3283
3354
  // CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling
3284
3355
  // of dispatch(). Wired by on_client: applies the declared op chain
3285
3356
  // (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,
@@ -4218,6 +4289,17 @@ export default class extends Controller {
4218
4289
  return (el) => this.#ownsField(el)
4219
4290
  }
4220
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
+
4221
4303
  // One walk over THIS root's named controls (not a nested reactive root's),
4222
4304
  // returning both the scalar `fields` and any chosen `files`. The ownership
4223
4305
  // predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
@@ -4229,7 +4311,7 @@ export default class extends Controller {
4229
4311
  #collectFields() {
4230
4312
  const fields = {}
4231
4313
  const files = []
4232
- const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
4314
+ const owns = this.#serverFieldFilter() // compute ONCE per dispatch (issue #117)
4233
4315
  const controls = []
4234
4316
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
4235
4317
  if (!owns(field)) return
@@ -42,9 +42,9 @@ module Phlex
42
42
  collection_remove: "reply.remove(model, from: :name)"
43
43
  }.freeze
44
44
 
45
- # rubocop:disable Style/ItBlockParameter -- `it` is illegal inside the
46
- # define_singleton_method block (it takes ordinary |*, **| params), so the
47
- # outer each_key must name its param explicitly.
45
+ # `it` is illegal inside the define_singleton_method block (it takes
46
+ # ordinary |*, **| params), so the outer each_key must name its param
47
+ # explicitly.
48
48
  REMOVED_CLASS_VERBS.each_key do |verb|
49
49
  define_singleton_method(verb) do |*, **|
50
50
  raise ArgumentError,
@@ -52,7 +52,6 @@ module Phlex
52
52
  "return #{REMOVED_CLASS_VERBS[verb]} from the action (reply is the only door)"
53
53
  end
54
54
  end
55
- # rubocop:enable Style/ItBlockParameter
56
55
 
57
56
  class << self
58
57
  # Re-render the component in place (explicit form of today's default).
@@ -625,7 +625,6 @@ module Phlex
625
625
  # Define the guided-error stub for each removed broadcast method (issue
626
626
  # #185). `verb` is referenced in define_method AND the message, so the outer
627
627
  # block param must be named — `it` is illegal here.
628
- # rubocop:disable-next Style/ItBlockParameter
629
628
  Phlex::Reactive::Streamable::REMOVED_BROADCASTS.each_key do |verb|
630
629
  define_method(verb) do |*, **|
631
630
  raise NoMethodError,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.4"
5
+ VERSION = "0.15.5"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.4
4
+ version: 0.15.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson