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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +26 -2
- data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +83 -1
- 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/response.rb +3 -4
- data/lib/phlex/reactive/shareable.rb +87 -0
- data/lib/phlex/reactive/store.rb +73 -0
- data/lib/phlex/reactive/streamable.rb +49 -20
- 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
|
@@ -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.#
|
|
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
|
-
#
|
|
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
|
|
@@ -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),
|