phlex-reactive 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +26 -2
  4. data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +3 -3
  7. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  8. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  9. data/app/javascript/phlex/reactive/runtime.js +83 -1
  10. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
  11. data/lib/phlex/reactive/apm/subscriber.rb +5 -0
  12. data/lib/phlex/reactive/apm.rb +5 -6
  13. data/lib/phlex/reactive/authorization.rb +31 -5
  14. data/lib/phlex/reactive/client_bindings.rb +19 -0
  15. data/lib/phlex/reactive/component/dsl.rb +15 -1
  16. data/lib/phlex/reactive/component/helpers.rb +4 -0
  17. data/lib/phlex/reactive/component/identity.rb +17 -6
  18. data/lib/phlex/reactive/component/lazy.rb +4 -0
  19. data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
  20. data/lib/phlex/reactive/component/registry.rb +76 -6
  21. data/lib/phlex/reactive/component/sortable.rb +3 -0
  22. data/lib/phlex/reactive/component.rb +4 -0
  23. data/lib/phlex/reactive/defer.rb +4 -4
  24. data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
  25. data/lib/phlex/reactive/doctor.rb +2 -0
  26. data/lib/phlex/reactive/engine.rb +25 -0
  27. data/lib/phlex/reactive/fragment.rb +5 -4
  28. data/lib/phlex/reactive/param_schema.rb +3 -2
  29. data/lib/phlex/reactive/pending/markup.rb +27 -9
  30. data/lib/phlex/reactive/pending.rb +4 -4
  31. data/lib/phlex/reactive/response.rb +3 -4
  32. data/lib/phlex/reactive/shareable.rb +87 -0
  33. data/lib/phlex/reactive/store.rb +73 -0
  34. data/lib/phlex/reactive/streamable.rb +49 -20
  35. data/lib/phlex/reactive/version.rb +1 -1
  36. data/lib/phlex/reactive.rb +293 -122
  37. data/sig/phlex/reactive/component.rbs +108 -0
  38. data/sig/phlex/reactive/param_schema.rbs +23 -0
  39. data/sig/phlex/reactive/response.rbs +70 -0
  40. data/sig/phlex/reactive/streamable.rbs +62 -0
  41. data/sig/phlex/reactive.rbs +231 -0
  42. metadata +9 -1
@@ -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
@@ -9,7 +9,8 @@
9
9
  # Phlex::Reactive.base_controller_name = "ApplicationController"
10
10
 
11
11
  # Render components with your app's view context (helpers, Current, etc.).
12
- # Phlex::Reactive.renderer = ApplicationController
12
+ # By name, so it follows code reloading in development.
13
+ # Phlex::Reactive.renderer = "ApplicationController"
13
14
 
14
15
  # Render your authorization library's error as 403 from a reactive action.
15
16
  # Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
@@ -19,6 +19,11 @@ module Phlex
19
19
  # adapter can subscribe to them itself) — the action IS the transaction.
20
20
  EVENTS = /\A(?:action|defer)\.phlex_reactive\z/
21
21
 
22
+ # The one subscription and its adapter, class ivars on purpose and
23
+ # safe: install/uninstall run at boot, from the engine's
24
+ # after_initialize on the main Ractor (APM.attach!), and between
25
+ # examples in a test suite; the request path never touches them.
26
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
22
27
  class << self
23
28
  # Install a single bus subscription for `adapter`. Idempotent for the
24
29
  # SAME adapter: a repeat call is a no-op. A DIFFERENT adapter replaces
@@ -41,7 +41,7 @@ module Phlex
41
41
  # Memoize the instance PER SYMBOL so repeated detect/attach! calls return
42
42
  # the SAME object — Subscriber.install keys idempotency on adapter
43
43
  # identity (equal?), so a fresh klass.new each time would double-subscribe.
44
- return (built_in_instances[apm] ||= klass.new) if klass.available?
44
+ return built_in_instances.fetch(apm) { klass.new } if klass.available?
45
45
 
46
46
  warn_and_nil("apm = #{apm.inspect} set but #{apm} is not loaded — no-op. " \
47
47
  "Require the SDK (or remove the setting).")
@@ -68,17 +68,16 @@ module Phlex
68
68
  def reset!
69
69
  Subscriber.uninstall
70
70
  Phlex::Reactive.resolved_apm_adapter = nil
71
- @built_in_instances = nil
71
+ built_in_instances.clear
72
72
  end
73
73
 
74
74
  private
75
75
 
76
76
  # Symbol => memoized built-in adapter instance. One instance per flavour for
77
77
  # the life of the process (cleared only by reset! in tests), so detect is
78
- # identity-stable and attach! stays idempotent.
79
- def built_in_instances
80
- @built_in_instances ||= {}
81
- end
78
+ # identity-stable and attach! stays idempotent. Per-Ractor state
79
+ # (Phlex::Reactive::Store), not a module ivar (zoolutions/phlex-reactive#364).
80
+ def built_in_instances = Phlex::Reactive::Store.table(:phlex_reactive_apm_adapters)
82
81
 
83
82
  def warn_and_nil(message)
84
83
  logger = Phlex::Reactive.send(:default_logger)
@@ -25,6 +25,10 @@ module Phlex
25
25
  MODULE_PREPEND = ::Module.instance_method(:prepend)
26
26
  private_constant :MODULE_PREPEND
27
27
 
28
+ # The per-Ractor lock (Phlex::Reactive::Store) the lazy instrument! path
29
+ # holds: one per Ractor, never shared across Ractors.
30
+ INSTRUMENT_LOCK_KEY = :phlex_reactive_authorization_instrument_lock
31
+
28
32
  class << self
29
33
  # Open a fresh tracking window, run the block, and restore the previous
30
34
  # cell — exactly the with_connection_id save/restore shape, so nesting is
@@ -73,14 +77,36 @@ module Phlex
73
77
  # a Zeitwerk reload's fresh class re-instruments naturally and a repeated
74
78
  # call in one process is a no-op. Re-reads authorization_methods each first
75
79
  # call, so an initializer that widened the list is honored.
80
+ #
81
+ # When the app eager-loads, the engine instruments every component class
82
+ # at boot (instrument_all!, zoolutions/phlex-reactive#364), so the
83
+ # endpoint's call is a bare "already instrumented" read and a request
84
+ # never mutates a class. The lazy path (development, or a class defined
85
+ # after boot) holds a lock across the check and the prepend, so two
86
+ # threads first-calling it for one class prepend ONE interceptor.
76
87
  def instrument!(component_class)
77
- return if component_class.instance_variable_get(:@reactive_authorization_instrumented)
88
+ return if instrumented?(component_class)
78
89
 
79
- names = Phlex::Reactive.authorization_methods.select do
80
- component_class.method_defined?(it) || component_class.private_method_defined?(it)
90
+ Phlex::Reactive::Store.fetch(INSTRUMENT_LOCK_KEY) { Mutex.new }.synchronize do
91
+ next if instrumented?(component_class)
92
+
93
+ names = Phlex::Reactive.authorization_methods.select do
94
+ component_class.method_defined?(it) || component_class.private_method_defined?(it)
95
+ end
96
+ MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
97
+ component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
81
98
  end
82
- MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
83
- component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
99
+ end
100
+
101
+ # Instrument every reactive component class among `classes` (the
102
+ # engine passes Streamable.registered_classes at boot, after eager
103
+ # loading). Plain Streamable classes have no actions to guard.
104
+ def instrument_all!(classes)
105
+ classes.each { instrument!(it) if it.include?(Phlex::Reactive::Component) }
106
+ end
107
+
108
+ def instrumented?(component_class)
109
+ component_class.instance_variable_get(:@reactive_authorization_instrumented) ? true : false
84
110
  end
85
111
 
86
112
  private
@@ -52,6 +52,25 @@ module Phlex
52
52
 
53
53
  include Component::DSL
54
54
  include Component::Helpers
55
+
56
+ # Tracked so the engine warms every class with the DSL at boot, a
57
+ # client-only one too (zoolutions/phlex-reactive#364). A subclass
58
+ # inherits the module rather than including it, so `included` never
59
+ # fires for it.
60
+ included do
61
+ Phlex::Reactive::Component::Registry.track(self)
62
+ end
63
+
64
+ class_methods do
65
+ def inherited(subclass)
66
+ super
67
+ Phlex::Reactive::Component::Registry.track(subclass)
68
+ end
69
+ end
70
+
71
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
72
+ # the body has loaded (zoolutions/phlex-reactive#364).
73
+ Phlex::Reactive::Shareable.seal_concern(self)
55
74
  end
56
75
  end
57
76
  end
@@ -174,6 +174,13 @@ module Phlex
174
174
  # and the registry generation (any declaration) — as two integer
175
175
  # compares, because reactive_attrs is the token-signing hot path.
176
176
  # The race on a concurrent first call is benign (idempotent rebuild).
177
+ #
178
+ # A per-class memo of class-definition data, safe as a class ivar:
179
+ # Registry.warm! builds it at boot on the main Ractor when the app
180
+ # eager-loads (so a request only reads it), and it is rebuilt only
181
+ # after a declaration or an effects= write (boot, a development
182
+ # reload, a spec).
183
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
177
184
  def reactive_effect_attrs
178
185
  effects_gen = Phlex::Reactive.effects_generation
179
186
  registry_gen = Registry.generation
@@ -583,7 +590,10 @@ module Phlex
583
590
  # frozen array of { any:, ops: } bindings, or nil when undeclared
584
591
  # (byte-stable wire). Memoized per class against the registry
585
592
  # generation (one integer compare) because reactive_attrs is the
586
- # token-signing hot path — the reactive_effect_attrs precedent.
593
+ # token-signing hot path — the reactive_effect_attrs precedent. A
594
+ # class ivar for the same reason as that one: warmed at boot,
595
+ # rebuilt only after a declaration.
596
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
587
597
  def reactive_on_complete_attr
588
598
  registry_gen = Registry.generation
589
599
  return @reactive_on_complete_attr if @reactive_on_complete_attr_gen == registry_gen
@@ -754,6 +764,10 @@ module Phlex
754
764
  "can rebuild through it."
755
765
  end
756
766
  end
767
+
768
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
769
+ # the body has loaded (zoolutions/phlex-reactive#364).
770
+ Phlex::Reactive::Shareable.seal_concern(self)
757
771
  end
758
772
  end
759
773
  end
@@ -1963,6 +1963,10 @@ module Phlex
1963
1963
 
1964
1964
  instance_variable_get(:"@#{key}")
1965
1965
  end
1966
+
1967
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
1968
+ # the body has loaded (zoolutions/phlex-reactive#364).
1969
+ Phlex::Reactive::Shareable.seal_concern(self)
1966
1970
  end
1967
1971
  end
1968
1972
  end
@@ -20,15 +20,21 @@ module Phlex
20
20
 
21
21
  # Whether the installed GlobalID gives a class its own app
22
22
  # (global_id_app, then GlobalID.app — rails/globalid after 1.4).
23
- # Read once: it is a fact about the loaded gem.
24
- def self.per_class_gid_app?
25
- return @per_class_gid_app if defined?(@per_class_gid_app)
23
+ # Read once, when this file loads: it is a fact about the loaded gem.
24
+ PER_CLASS_GID_APP = (::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
25
+ ::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)) == true
26
26
 
27
- @per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
28
- ::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
29
- end
27
+ def self.per_class_gid_app? = PER_CLASS_GID_APP
30
28
 
31
29
  class_methods do
30
+ # Both memos are class ivars by design (the hot-path contract above),
31
+ # and safe as such: Registry.warm! builds them at boot on the main
32
+ # Ractor when the app eager-loads, so a request only reads them, and
33
+ # Registry.bump! clears them only when a declaration lands (boot, a
34
+ # development reload). A first-render race in development rebuilds
35
+ # an equal value.
36
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
37
+
32
38
  # The record's instance-variable symbol (e.g. :@todo), computed once.
33
39
  # reactive_token reads it on every render; interpolating :"@#{key}" each
34
40
  # time would allocate a symbol per render. Nil when record-less.
@@ -44,6 +50,7 @@ module Phlex
44
50
  def reactive_state_ivars
45
51
  @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
46
52
  end
53
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
47
54
  end
48
55
 
49
56
  private
@@ -128,6 +135,10 @@ module Phlex
128
135
  def reactive_gid_app(klass)
129
136
  (Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
130
137
  end
138
+
139
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
140
+ # the body has loaded (zoolutions/phlex-reactive#364).
141
+ Phlex::Reactive::Shareable.seal_concern(self)
131
142
  end
132
143
  end
133
144
  end
@@ -209,6 +209,10 @@ module Phlex
209
209
  value.html_safe? ? raw(value) : plain(value.to_s)
210
210
  end
211
211
  end
212
+
213
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
214
+ # the body has loaded (zoolutions/phlex-reactive#364).
215
+ Phlex::Reactive::Shareable.seal_concern(self)
212
216
  end
213
217
  end
214
218
  end
@@ -112,6 +112,10 @@ module Phlex
112
112
  "would never join the stream. Drop reactive_dormant on this component."
113
113
  end
114
114
  end
115
+
116
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
117
+ # the body has loaded (zoolutions/phlex-reactive#364).
118
+ Phlex::Reactive::Shareable.seal_concern(self)
115
119
  end
116
120
  end
117
121
  end
@@ -75,11 +75,48 @@ module Phlex
75
75
  # above). Owned by Component::Identity; invalidated here.
76
76
  HOT_PATH_MEMOS = %i[@reactive_record_ivar @reactive_state_ivars].freeze
77
77
 
78
+ # The public reader each registry kind resolves through: what warm!
79
+ # calls, so a kind added to OWN_IVARS needs its reader here too (the
80
+ # warm spec fails otherwise).
81
+ READERS = {
82
+ actions: :reactive_actions,
83
+ state_keys: :reactive_state_keys,
84
+ collections: :reactive_collections,
85
+ computes: :reactive_computes,
86
+ on_completes: :reactive_on_completes,
87
+ record_key: :reactive_record_key,
88
+ scope: :reactive_scope,
89
+ dirty: :reactive_dirty_config,
90
+ lazy: :reactive_lazy_declaration,
91
+ dormant: :reactive_dormant?,
92
+ effects: :reactive_effects_config,
93
+ skip_all: :reactive_skip_all_authorization?,
94
+ skip_actions: :reactive_skip_authorization_actions,
95
+ presence: :reactive_presence_declaration
96
+ }.freeze
97
+
98
+ # The per-class memos the render path reads (Identity's token memos,
99
+ # the DSL's root-attrs fragments): warm! builds them too.
100
+ MEMO_READERS = %i[reactive_record_ivar reactive_state_ivars reactive_effect_attrs reactive_on_complete_attr].freeze
101
+
102
+ # Where those readers memoize: the gem's own plain data (ivar names,
103
+ # attribute fragments), shared by warm! so a non-main Ractor can read it.
104
+ MEMO_IVARS = %i[@reactive_record_ivar @reactive_state_ivars @reactive_effect_attrs @reactive_on_complete_attr].freeze
105
+
106
+ # Every class with the reactive DSL, a ClientBindings-only view
107
+ # included (Streamable's registry has only the token-bearing ones):
108
+ # what the engine warms at boot. Per-Ractor (Phlex::Reactive::Store);
109
+ # classes are defined on the main Ractor.
110
+ CLASSES_KEY = :phlex_reactive_component_classes
111
+
78
112
  # Serializes generation bumps so two concurrent class definitions can't
79
113
  # lose an increment (a resolution memoized between the two writes must
80
114
  # see a THIRD generation, not the second one again). Reads stay bare —
81
- # the resolution compare tolerates staleness (it just recomputes).
82
- WRITE_MUTEX = Mutex.new
115
+ # the resolution compare tolerates staleness (it just recomputes). The
116
+ # lock is per-Ractor state (Phlex::Reactive::Store) rather than a Mutex
117
+ # constant, which no other Ractor could read (zoolutions/phlex-reactive#364);
118
+ # classes are declared on the main Ractor, so it is one lock in practice.
119
+ WRITE_LOCK_KEY = :phlex_reactive_registry_write_lock
83
120
 
84
121
  # Frozen empties for the no-declaration branches of resolve_* — the
85
122
  # merge/+ still returns a fresh, per-class copy.
@@ -120,6 +157,26 @@ module Phlex
120
157
  value
121
158
  end
122
159
 
160
+ # -- boot warm-up (zoolutions/phlex-reactive#364) --------------------
161
+
162
+ # Resolve every registry kind and build the render path's per-class
163
+ # memos for each reactive component class among `classes`, then
164
+ # freeze each class's resolution cache. The engine runs it at boot
165
+ # when the app eager-loads (every class is loaded by then), so a
166
+ # request reads memos and never writes the class. A declaration
167
+ # after it (a development reload) bumps the generation and the next
168
+ # read rebuilds a fresh cache, as before.
169
+ def warm!(classes)
170
+ classes.each { warm_class(it) if it.respond_to?(:reactive_actions) }
171
+ end
172
+
173
+ # Called when a class gains the DSL (ClientBindings' included and
174
+ # inherited hooks), at class definition.
175
+ def track(klass) = class_registry.add(klass)
176
+
177
+ # A snapshot of every tracked class still loaded.
178
+ def registered_classes = class_registry.to_a
179
+
123
180
  # -- resolution (all memoized against the generation) -----------------
124
181
  #
125
182
  # `reader` is the PUBLIC reader to walk the superclass through
@@ -179,7 +236,7 @@ module Phlex
179
236
  # and sweep the hot-path identity memos off the writing class's
180
237
  # family (see the contract in the module doc).
181
238
  def bump!(klass)
182
- WRITE_MUTEX.synchronize { @generation += 1 }
239
+ Phlex::Reactive::Store.fetch(WRITE_LOCK_KEY) { Mutex.new }.synchronize { @generation += 1 }
183
240
  sweep_hot_path_memos(klass)
184
241
  end
185
242
 
@@ -191,14 +248,27 @@ module Phlex
191
248
  end
192
249
 
193
250
  # The generation-checked per-class memo: a cache built at an older
194
- # generation is discarded wholesale and rebuilt lazily per kind.
251
+ # generation is discarded wholesale and rebuilt lazily per kind. A
252
+ # cache warm! froze answers a kind it lacks without memoizing it.
195
253
  def fetch(klass, kind)
196
254
  cache = resolution_cache(klass)
197
- cache.fetch(kind) { cache[kind] = yield }
255
+ cache.fetch(kind) { cache.frozen? ? yield : (cache[kind] = yield) }
198
256
  end
199
257
 
258
+ def warm_class(klass)
259
+ [*READERS.values, *MEMO_READERS].each { klass.public_send(it) if klass.respond_to?(it) }
260
+ klass.instance_variable_get(:@reactive_registry_cache)&.freeze
261
+ MEMO_IVARS.each do
262
+ next unless klass.instance_variable_defined?(it)
263
+
264
+ klass.instance_variable_set(it, Phlex::Reactive::Shareable.share(klass.instance_variable_get(it)))
265
+ end
266
+ end
267
+
268
+ def class_registry = Phlex::Reactive::Store.fetch(CLASSES_KEY) { Phlex::Reactive::Streamable::ClassRegistry.new }
269
+
200
270
  def resolution_cache(klass)
201
- generation = @generation
271
+ generation = self.generation
202
272
  if klass.instance_variable_get(:@reactive_registry_generation) == generation
203
273
  klass.instance_variable_get(:@reactive_registry_cache)
204
274
  else
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # SORTABLE_MESSAGES_JSON is built with Hash#to_json while this file loads.
4
+ require "json"
5
+
3
6
  module Phlex
4
7
  module Reactive
5
8
  module Component
@@ -147,6 +147,10 @@ module Phlex
147
147
  include Identity
148
148
  include Lazy
149
149
  include PresenceDeclaration
150
+
151
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
152
+ # the body has loaded (zoolutions/phlex-reactive#364).
153
+ Phlex::Reactive::Shareable.seal_concern(self)
150
154
  end
151
155
  end
152
156
  end
@@ -350,11 +350,11 @@ data-reactive-defer-pending="true" aria-busy="true">#{inner}</div>)
350
350
 
351
351
  # A forced :stream without the capability is a CONFIG mistake — warn
352
352
  # loudly but once per process (a deferred reply can fire per keystroke;
353
- # per-reply spam would bury the signal), then degrade to :fetch.
353
+ # per-reply spam would bury the signal), then degrade to :fetch. The
354
+ # "warned" flag is per-Ractor state (Store), never a module ivar written
355
+ # on the request path.
354
356
  def warn_stream_degraded
355
- return if @stream_degraded_warned
356
-
357
- @stream_degraded_warned = true
357
+ return unless Phlex::Reactive::Store.first_warning?(:defer_stream_degraded)
358
358
  return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
359
359
 
360
360
  ::Rails.logger.warn(
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ class Doctor
6
+ # Ractor mode (zoolutions/phlex-reactive#364): a non-main Ractor can read
7
+ # phlex-reactive's configuration once it is finalized and every setting
8
+ # is Ractor-shareable, and the request path writes no class only when
9
+ # every component class was loaded, instrumented and warmed at boot,
10
+ # which needs eager loading.
11
+ #
12
+ # Advisory only (never ✗): no app has to run Ractors, and Rails, the
13
+ # ActiveSupport::MessageVerifier behind Phlex::Reactive.verifier, and
14
+ # Phlex rendering still block it upstream. Never loads a server.
15
+ module RactorReadiness
16
+ UPSTREAM = "Ractor mode still waits on Rails, on ActiveSupport (the verifier is an " \
17
+ "ActiveSupport::MessageVerifier) and on Phlex rendering (yippee-fun/phlex#1055)"
18
+
19
+ SHARE_FIX = "Make each value Ractor-shareable: a callable as a capture-free lambda in a module or " \
20
+ "class body (Phlex::Reactive.finalize! shares it), any other object deep-frozen with " \
21
+ "Ractor.make_shareable before you assign it. A value that cannot be shared (a logger, an " \
22
+ "SDK client) keeps working on the main Ractor only; or leave Ractor mode off."
23
+
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ # Setting => the class of its value, for every configuration reader
30
+ # (Phlex::Reactive::CONFIG_READERS) whose value is not
31
+ # Ractor-shareable. The verifier is left out: it is never shareable.
32
+ def unshareable_settings
33
+ Phlex::Reactive::CONFIG_READERS.each_with_object({}) do |reader, out|
34
+ value = Phlex::Reactive.public_send(reader)
35
+ out[reader] = value.class unless Phlex::Reactive::Shareable.shareable?(value)
36
+ end
37
+ end
38
+
39
+ def eager_load? = defined?(::Rails) && ::Rails.application ? ::Rails.application.config.eager_load : false
40
+ end
41
+
42
+ def ractor_readiness_check(eager_load: Doctor.eager_load?, finalized: Phlex::Reactive.finalized?,
43
+ unshareable: Doctor.unshareable_settings)
44
+ if !eager_load
45
+ advise("eager loading is off: Ractor mode needs every component loaded and prepared at boot",
46
+ "config.eager_load = true (the production default)")
47
+ elsif !finalized
48
+ advise("the configuration is not finalized",
49
+ "Boot through the engine (it calls Phlex::Reactive.finalize! once the app's initializers have run), " \
50
+ "or call Phlex::Reactive.finalize! at the end of your own boot.")
51
+ elsif unshareable.any?
52
+ names = unshareable.map { |name, klass| "#{name} (#{klass})" }.join(", ")
53
+ advise("#{unshareable.size} settings hold a value a non-main Ractor cannot read: #{names}", SHARE_FIX)
54
+ else
55
+ Check.new(:ok, "the configuration is finalized and Ractor-shareable; #{UPSTREAM}", name: :ractor_readiness)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def advise(message, fix) = Check.new(:unknown, "Ractor mode: #{message}", name: :ractor_readiness, fix:)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -17,6 +17,7 @@ module Phlex
17
17
  # trip and (for the component checks) iterate the loaded Streamable registry.
18
18
  class Doctor
19
19
  include FiberIsolation
20
+ include RactorReadiness
20
21
 
21
22
  # The result of one check: a status (:ok/:fail/:unknown), a human message,
22
23
  # and (on anything but :ok) a fix line telling the adopter what to do. A
@@ -79,6 +80,7 @@ module Phlex
79
80
  verifier_check,
80
81
  base_controller_check,
81
82
  fiber_isolation_check,
83
+ ractor_readiness_check,
82
84
  action_check(components),
83
85
  id_check(components),
84
86
  authorization_check(components),