phlex-reactive 0.15.5 → 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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +15 -2
  4. data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
  5. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
  6. data/lib/phlex/reactive/apm/subscriber.rb +5 -0
  7. data/lib/phlex/reactive/apm.rb +5 -6
  8. data/lib/phlex/reactive/authorization.rb +31 -5
  9. data/lib/phlex/reactive/client_bindings.rb +19 -0
  10. data/lib/phlex/reactive/component/dsl.rb +15 -1
  11. data/lib/phlex/reactive/component/helpers.rb +4 -0
  12. data/lib/phlex/reactive/component/identity.rb +17 -6
  13. data/lib/phlex/reactive/component/lazy.rb +4 -0
  14. data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
  15. data/lib/phlex/reactive/component/registry.rb +76 -6
  16. data/lib/phlex/reactive/component/sortable.rb +3 -0
  17. data/lib/phlex/reactive/component.rb +4 -0
  18. data/lib/phlex/reactive/defer.rb +4 -4
  19. data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
  20. data/lib/phlex/reactive/doctor.rb +2 -0
  21. data/lib/phlex/reactive/engine.rb +25 -0
  22. data/lib/phlex/reactive/fragment.rb +5 -4
  23. data/lib/phlex/reactive/param_schema.rb +3 -2
  24. data/lib/phlex/reactive/pending/markup.rb +27 -9
  25. data/lib/phlex/reactive/pending.rb +4 -4
  26. data/lib/phlex/reactive/shareable.rb +87 -0
  27. data/lib/phlex/reactive/store.rb +73 -0
  28. data/lib/phlex/reactive/streamable.rb +49 -19
  29. data/lib/phlex/reactive/version.rb +1 -1
  30. data/lib/phlex/reactive.rb +293 -122
  31. data/sig/phlex/reactive/component.rbs +108 -0
  32. data/sig/phlex/reactive/param_schema.rbs +23 -0
  33. data/sig/phlex/reactive/response.rbs +70 -0
  34. data/sig/phlex/reactive/streamable.rbs +62 -0
  35. data/sig/phlex/reactive.rbs +231 -0
  36. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb2e7841a38e3400b1777833cde2f16a73794b54c29f9e0ceb1a2b02c358263d
4
- data.tar.gz: 8023c8ab7f9bb88833282c33c99a7797f565fb491795cf9b0c7566d0040653ef
3
+ metadata.gz: b96289b4a91d050b1631c5c31663714e3aba33370f1a4e8d87d04c662fa3ed81
4
+ data.tar.gz: 55c7126c343c696e66f4baf5d4c62d09a2e8fa8861c1bbe04b9b5f67c265c42c
5
5
  SHA512:
6
- metadata.gz: 5257d059749e070f6b7e369e89c06fbca8b5c6b0cb9d6663b7983081a9af5a7831ba7f4d46b01da97d04a49f5fe727b6d760351f449d140e4ccffb2881cfd7c3
7
- data.tar.gz: 0e29f1ca596e632855b89c9dacf2b1e0e4b6fc0db3f836f662d61c8349c0fbda0f11ebb706fe165acfcfb4ed9fbcd4d630154db8f25c023b8d7ce547dbaf9cab
6
+ metadata.gz: c4298ac14bf00c485324d9b61ae72fb812e0c87f1afd66cc09a24c2f0dd0917b7bf40458dd37223ba79650b4cf8a3bd44745cb7bf66d74f27f19021a3969b1a5
7
+ data.tar.gz: a652652cfd5a2c7439c846afb421c8d99ff3cf902276da910e05228c7313cf9c3662c8c5391cfaab1316ec4399115d96cd5441fa9d4a8dbee74e032831d419d5
data/CHANGELOG.md CHANGED
@@ -6,6 +6,106 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.16.0]
10
+
11
+ ### Upgrading from 0.15.x
12
+
13
+ - **The configuration is finalized automatically at boot (#364).** Nothing to
14
+ call: the engine runs `Phlex::Reactive.finalize!` once the app has booted,
15
+ after every initializer and every `after_initialize` block the app
16
+ registered. From then on every setting reads back as a frozen copy, and the
17
+ param-type and named-schema registries are closed (registering into either
18
+ after boot already raised). Booting the gem without the engine, call
19
+ `Phlex::Reactive.finalize!` at the end of your own boot.
20
+
21
+ - **The configuration's lists are frozen once the app has booted (#364).**
22
+ Append to `authorization_errors` in an initializer or an `after_initialize`
23
+ block, as before; after boot, assign a new list (`<<` raises `FrozenError`).
24
+ `around_action`, `on_action_error` and `register_token_upgrader` now replace
25
+ a frozen list instead of appending to it; `around_actions`,
26
+ `on_action_error_hooks` and `token_upgraders` are frozen, and
27
+ `authorization_methods`' default is a frozen list. A setting reads back
28
+ frozen too: change it by assigning, never in place.
29
+
30
+ - **Nothing an app configures makes boot raise (#364).** A value no Ractor can
31
+ share is kept as it is and works on the main Ractor only: a block written in
32
+ an initializer (`around_action`, `on_action_error`, `param_type`), a lambda
33
+ that closes over app state, a logger, an APM object, the verifier. An app
34
+ booting without a `secret_key_base` (a production rake task or build step)
35
+ keeps the verifier lazy, as before: the first token raises, not boot.
36
+
37
+ ### Added
38
+
39
+ - **`Phlex::Reactive.finalize!`, `finalized?` and a test-only
40
+ `reset_finalization!` (#364).** `finalize!` freezes the param-type and
41
+ named-schema registries and stores every setting as a deep-frozen,
42
+ Ractor-shareable copy (the objects an app handed in are never frozen), so a
43
+ non-main Ractor can read the configuration. The engine calls it at boot (see
44
+ Upgrading). Writers keep working afterwards, and what they write is shared
45
+ too.
46
+
47
+ - **`Phlex::Reactive.renderer` takes a name (#364).** `renderer =
48
+ "ApplicationController"`, like `base_controller_name`, resolves on every read,
49
+ so a class Zeitwerk reloaded in development is picked up (and the per-thread
50
+ view contexts rebuild for it). A class keeps working, and keeps rendering the
51
+ class object it was given. The README and the install template show the name.
52
+
53
+ - **`phlex_reactive:doctor` reports Ractor readiness (#364).** An advisory line
54
+ (never a failure): eager loading on, the configuration finalized, and each
55
+ setting `finalize!` shares that still holds a value a non-main Ractor cannot
56
+ read, with its class (the verifier, never shareable, is left out).
57
+
58
+ - **RBS signatures (#364).** `sig/` covers the configuration and token surface
59
+ of `Phlex::Reactive`, a component's class-level DSL, Streamable (its instance
60
+ methods and its class methods), `Response`, `ParamSchema`, `Store` and
61
+ `Shareable`, and ships with the gem. Lint runs `rbs -I sig validate`; a spec
62
+ fails when a public method has no signature.
63
+
64
+ - **Concurrency conformance (#364).** conformance-kit 0.4's gates run in CI:
65
+ every `rubocop-thread_safety` cop, with no directory excludes (`NewThread`
66
+ is excluded only from the five spec files that start threads on purpose),
67
+ thread and Async contention examples over rendering, tokens, params and
68
+ broadcasts, a
69
+ Ruby 4.0 Ractor canary (param coercion, Stream wrapping, `Streamable#dom_id`,
70
+ configuration reads and token upgrades run in a non-main Ractor; rendering and
71
+ signing are pending on Phlex and ActiveSupport), and `conformance-audit
72
+ --strict`. A report-only kino smoke serves the dummy app in Ractor mode and
73
+ records the first blocker. Docs: a new "Concurrency & Ractors" guide page.
74
+
75
+ ### Changed
76
+
77
+ - **No reader writes on first use (#364).** Every setting starts at its default
78
+ when the gem loads (the param-type registry is seeded then too; the verifier,
79
+ which needs `Rails.application`, is built by `finalize!` at boot when it can
80
+ be). With eager loading the engine also prepends `verify_authorized`'s
81
+ interceptor to every component and resolves every component's registries and
82
+ render memos at boot (a `ClientBindings`-only view's too), so a request
83
+ writes no class.
84
+
85
+ - **Per-Ractor state instead of `Concurrent::Map` and lock constants (#364).**
86
+ The fragment viewer warnings, the CSRF-name patterns, the once-per-process
87
+ config warnings, the APM adapter memo, the Streamable class registry, the
88
+ pending-markup variants and the registry's write lock live in
89
+ `Phlex::Reactive::Store` (`Ractor.store_if_absent`, with a Mutex-guarded
90
+ table on an engine without Ractors). `lib/` and `app/` no longer reference
91
+ `concurrent-ruby`, which was never a declared dependency.
92
+
93
+ ### Fixed
94
+
95
+ - **Eager loading the gem raised `NoMethodError: undefined method 'to_json'`
96
+ in a process that had not loaded json yet (#364).** `Component::Sortable`
97
+ calls `Hash#to_json` while it loads; it now requires json itself.
98
+
99
+ - **`verify_authorized` could prepend two interceptors to one class (#364).**
100
+ The lazy, request-path `Authorization.instrument!` checked and prepended
101
+ without a lock; two threads first-calling it for one class both prepended.
102
+ It now holds a lock across the check and the prepend.
103
+
104
+ ## [0.15.5] - 2026-10-15
105
+
106
+ Consolidates everything since 0.14.1; tags 0.14.2–0.15.4 shipped without
107
+ changelog sections, so this section spans the whole run to 0.15.5.
108
+
9
109
  ### Added
10
110
 
11
111
  - **A server deadline counted down on the client — `reactive_countdown(until:, format: :ms, waiting:, done:)` (#400).**
data/README.md CHANGED
@@ -3936,8 +3936,10 @@ Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
3936
3936
  Phlex::Reactive.authorization_methods = %i[authorize! authorize allowed_to?]
3937
3937
  # Phlex::Reactive.verify_authorized = false # turn the guard off (not recommended)
3938
3938
 
3939
- # Use your ApplicationController to render components (app helpers / Current):
3940
- Phlex::Reactive.renderer = ApplicationController
3939
+ # Use your ApplicationController to render components (app helpers / Current).
3940
+ # By name, like base_controller_name, it follows a code reload in development;
3941
+ # a Class keeps rendering the class object it was given:
3942
+ Phlex::Reactive.renderer = "ApplicationController"
3941
3943
 
3942
3944
  # Sign tokens with a dedicated key instead of secret_key_base:
3943
3945
  Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
@@ -3989,6 +3991,17 @@ Phlex::Reactive.around_action do |ctx, &action|
3989
3991
  end
3990
3992
  ```
3991
3993
 
3994
+ **The configuration is finalized at boot.** Once the app has booted (after your
3995
+ initializers and every `after_initialize` block), the engine calls
3996
+ `Phlex::Reactive.finalize!` for you: the param-type and named-schema registries
3997
+ freeze, and every setting is stored as a frozen, Ractor-shareable copy (the
3998
+ objects you handed in are not frozen). Append to `authorization_errors` in an
3999
+ initializer; after boot, assign a new list (`<<` raises `FrozenError`). A value
4000
+ no Ractor can share (a block written in an initializer, a lambda that closes
4001
+ over app state, a logger, an APM object, the verifier) is kept as it is and
4002
+ works on the main Ractor only: finalizing never raises for it. Booting without
4003
+ the engine? Call `Phlex::Reactive.finalize!` at the end of your own boot.
4004
+
3992
4005
  #### Two seams: HTTP-layer (`base_controller_name`) vs component-layer (`around_action`)
3993
4006
 
3994
4007
  There are two places to wrap a reactive action, and they see different things:
@@ -45,8 +45,9 @@ module Phlex
45
45
  # the session it was minted for.
46
46
  CSRF_TOKEN_NAMES = %w[authenticity_token csrf-token].freeze
47
47
  # One compiled pattern per forgery-protection field name (it is a
48
- # class-level setting, so in practice one entry), not one per request.
49
- CSRF_TOKEN_PATTERNS = Concurrent::Map.new
48
+ # class-level setting, so in practice one entry), not one per request:
49
+ # memoized in the per-Ractor table (Phlex::Reactive::Store) under this key.
50
+ CSRF_TOKEN_PATTERNS_KEY = :phlex_reactive_csrf_token_patterns
50
51
 
51
52
  # Stands in for the response when asking the cookie jar what it WOULD
52
53
  # write (CookieJar#write calls set_cookie / delete_cookie per pending
@@ -261,7 +262,7 @@ module Phlex
261
262
  # An API base controller has no forgery protection (and so no such
262
263
  # setting): the built-in names alone are checked then.
263
264
  token_name = respond_to?(:request_forgery_protection_token, true) ? request_forgery_protection_token.to_s : ""
264
- CSRF_TOKEN_PATTERNS.compute_if_absent(token_name) do
265
+ Phlex::Reactive::Store.table(CSRF_TOKEN_PATTERNS_KEY).fetch(token_name) do
265
266
  names = (CSRF_TOKEN_NAMES | [token_name]).reject(&:empty?).map { Regexp.escape(it) }
266
267
  /\bname\s*=\s*["']?(?:#{names.join("|")})(?![\w-])/i
267
268
  end
@@ -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