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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +15 -2
- data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
- data/lib/phlex/reactive/apm/subscriber.rb +5 -0
- data/lib/phlex/reactive/apm.rb +5 -6
- data/lib/phlex/reactive/authorization.rb +31 -5
- data/lib/phlex/reactive/client_bindings.rb +19 -0
- data/lib/phlex/reactive/component/dsl.rb +15 -1
- data/lib/phlex/reactive/component/helpers.rb +4 -0
- data/lib/phlex/reactive/component/identity.rb +17 -6
- data/lib/phlex/reactive/component/lazy.rb +4 -0
- data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
- data/lib/phlex/reactive/component/registry.rb +76 -6
- data/lib/phlex/reactive/component/sortable.rb +3 -0
- data/lib/phlex/reactive/component.rb +4 -0
- data/lib/phlex/reactive/defer.rb +4 -4
- data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
- data/lib/phlex/reactive/doctor.rb +2 -0
- data/lib/phlex/reactive/engine.rb +25 -0
- data/lib/phlex/reactive/fragment.rb +5 -4
- data/lib/phlex/reactive/param_schema.rb +3 -2
- data/lib/phlex/reactive/pending/markup.rb +27 -9
- data/lib/phlex/reactive/pending.rb +4 -4
- data/lib/phlex/reactive/shareable.rb +87 -0
- data/lib/phlex/reactive/store.rb +73 -0
- data/lib/phlex/reactive/streamable.rb +49 -19
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +293 -122
- data/sig/phlex/reactive/component.rbs +108 -0
- data/sig/phlex/reactive/param_schema.rbs +23 -0
- data/sig/phlex/reactive/response.rbs +70 -0
- data/sig/phlex/reactive/streamable.rbs +62 -0
- data/sig/phlex/reactive.rbs +231 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b96289b4a91d050b1631c5c31663714e3aba33370f1a4e8d87d04c662fa3ed81
|
|
4
|
+
data.tar.gz: 55c7126c343c696e66f4baf5d4c62d09a2e8fa8861c1bbe04b9b5f67c265c42c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/phlex/reactive/apm.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
88
|
+
return if instrumented?(component_class)
|
|
78
89
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
@@ -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
|
data/lib/phlex/reactive/defer.rb
CHANGED
|
@@ -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
|
|
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
|