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
@@ -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),
@@ -208,6 +208,31 @@ module Phlex
208
208
  # app on Action Cable boots exactly as before.
209
209
  Phlex::Reactive::Presence.install!
210
210
  end
211
+
212
+ # Finalize the configuration once boot is over (zoolutions/phlex-reactive#364).
213
+ # The after_initialize block is registered from an initializer that runs
214
+ # after the app's config/initializers, so it runs after every
215
+ # after_initialize block the app registered there or in
216
+ # config/application.rb: one that appends to authorization_errors (the
217
+ # documented pattern for an app's own constants) still finds the list open.
218
+ initializer "phlex_reactive.finalize", after: :load_config_initializers do
219
+ config.after_initialize { Phlex::Reactive::Engine.finalize_boot! }
220
+ end
221
+
222
+ # What the finalize hook runs; public so a spec can drive it. With eager
223
+ # loading every component class is loaded by now, so verify_authorized's
224
+ # interceptor is prepended to every token-bearing class, and every
225
+ # class's registries and render memos (a client-only view's too) are
226
+ # resolved here, at boot, rather than by the first request for each
227
+ # class (Authorization.instrument_all!, Component::Registry.warm!).
228
+ def self.finalize_boot!(eager_load: ::Rails.application.config.eager_load)
229
+ registry = Phlex::Reactive::Component::Registry
230
+ if Phlex::Reactive.verify_authorized
231
+ Phlex::Reactive::Authorization.instrument_all!(eager_load ? Phlex::Reactive::Streamable.registered_classes : [])
232
+ end
233
+ Phlex::Reactive.finalize!
234
+ registry.warm!(eager_load ? registry.registered_classes : [])
235
+ end
211
236
  end
212
237
  end
213
238
  end
@@ -41,8 +41,9 @@ module Phlex
41
41
  # a collection is walked, so no value can make the shell render slow.
42
42
  VIEWER_PART_LIMIT = 32
43
43
  VIEWER_NODE_LIMIT = 64
44
- # Components already warned about (by class name): once per component.
45
- VIEWER_WARNED = Concurrent::Map.new
44
+ # The per-Ractor table (Phlex::Reactive::Store) of the components already
45
+ # warned about, by class name: once per component.
46
+ VIEWER_WARNINGS_KEY = :phlex_reactive_fragment_viewer_warnings
46
47
 
47
48
  # A reactive_cache_viewer value that cannot name a viewer. Internal: it
48
49
  # carries the reason from the walk to viewer_param, which logs it.
@@ -207,7 +208,7 @@ module Phlex
207
208
  # component's hook — and answers nil: "this value names nobody".
208
209
  def warn_unusable_viewer(owner, reason)
209
210
  name = owner.respond_to?(:name) ? owner.name.to_s : owner.to_s
210
- return nil if VIEWER_WARNED.put_if_absent(name, true)
211
+ return nil unless Phlex::Reactive::Store.table(VIEWER_WARNINGS_KEY).claim?(name)
211
212
  return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
212
213
 
213
214
  ::Rails.logger&.warn(
@@ -220,7 +221,7 @@ module Phlex
220
221
 
221
222
  # Test seam: let the once-per-component warnings fire again.
222
223
  def reset_viewer_warning!
223
- VIEWER_WARNED.clear
224
+ Phlex::Reactive::Store.table(VIEWER_WARNINGS_KEY).clear
224
225
  end
225
226
 
226
227
  # The max-age (seconds) the endpoint answers with: the component's
@@ -136,8 +136,9 @@ module Phlex
136
136
 
137
137
  # ActiveModel's Boolean type, instantiated once — the exact caster the
138
138
  # controller used (an unrecognized value casts to nil, which is KEPT, not
139
- # dropped).
140
- BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
139
+ # dropped). Ractor-shareable (it holds no state a cast writes), so a
140
+ # non-main Ractor can coerce through it (zoolutions/phlex-reactive#364).
141
+ BOOLEAN_TYPE = defined?(::Ractor) ? ::Ractor.make_shareable(ActiveModel::Type::Boolean.new) : ActiveModel::Type::Boolean.new
141
142
  private_constant :BOOLEAN_TYPE
142
143
 
143
144
  attr_reader :schema
@@ -49,11 +49,28 @@ module Phlex
49
49
  # the first `>`.
50
50
  ROOT_TAG = %r{\A\s*<[^\s>/]+(?:\s+[^\s=>/]+(?:="[^"]*")?)*}
51
51
 
52
- # Keyed weakly on the row class, so a class Zeitwerk replaced is not
53
- # pinned between resets. A WeakKeyMap, not a WeakMap: nothing else holds
54
- # the variant, so a weak VALUE would let GC drop the memo.
55
- @variants = ObjectSpace::WeakKeyMap.new
56
- @mutex = Mutex.new
52
+ # The variant memo, keyed weakly on the row class, so a class Zeitwerk
53
+ # replaced is not pinned between resets. A WeakKeyMap, not a WeakMap:
54
+ # nothing else holds the variant, so a weak VALUE would let GC drop the
55
+ # memo. Per-Ractor state (Phlex::Reactive::Store,
56
+ # zoolutions/phlex-reactive#364): a WeakKeyMap and its Mutex in module
57
+ # ivars could not be read from any other Ractor.
58
+ class Variants
59
+ def initialize
60
+ @mutex = Mutex.new
61
+ @map = ObjectSpace::WeakKeyMap.new
62
+ end
63
+
64
+ def fetch(row_class)
65
+ @mutex.synchronize { @map[row_class] ||= yield }
66
+ end
67
+
68
+ def reset!
69
+ @mutex.synchronize { @map = ObjectSpace::WeakKeyMap.new }
70
+ end
71
+ end
72
+
73
+ VARIANTS_KEY = :phlex_reactive_pending_variants
57
74
 
58
75
  class << self
59
76
  # The row's pending markup as HTML, or nil when the row has no hook
@@ -78,13 +95,14 @@ module Phlex
78
95
  end
79
96
 
80
97
  def variant_for(row_class)
81
- @mutex.synchronize { @variants[row_class] ||= build_variant(row_class) }
98
+ variants.fetch(row_class) { build_variant(row_class) }
82
99
  end
83
100
 
84
101
  # Called from the engine's config.to_prepare (Rails code reload).
85
- def reset!
86
- @mutex.synchronize { @variants = ObjectSpace::WeakKeyMap.new }
87
- end
102
+ def reset! = variants.reset!
103
+
104
+ # This Ractor's variant memo.
105
+ def variants = Phlex::Reactive::Store.fetch(VARIANTS_KEY) { Variants.new }
88
106
 
89
107
  private
90
108
 
@@ -322,11 +322,11 @@ data-reactive-defer-src="#{ERB::Util.html_escape(src)}" data-reactive-defer-sinc
322
322
 
323
323
  # No settle lane is a CONFIG fact, not a per-reply event — warn once per
324
324
  # process (a pending reply can fire per click; per-reply spam would bury
325
- # the signal), then degrade to a plain enqueue.
325
+ # the signal), then degrade to a plain enqueue. The "warned" flag is
326
+ # per-Ractor state (Store), never a module ivar written on the request
327
+ # path.
326
328
  def warn_no_lane
327
- return if @no_lane_warned
328
-
329
- @no_lane_warned = true
329
+ return unless Phlex::Reactive::Store.first_warning?(:pending_no_lane)
330
330
  return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
331
331
 
332
332
  ::Rails.logger.warn(
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Ractor-shareable values for the state the gem keeps on its modules and
6
+ # classes (zoolutions/phlex-reactive#364): a non-main Ractor can read a
7
+ # module instance variable or a constant only when its value is
8
+ # Ractor-shareable.
9
+ module Shareable
10
+ module_function
11
+
12
+ # A Ractor-shareable version of value when one can be made, else value
13
+ # itself (frozen when it is a collection). Never freezes or copies
14
+ # anything the caller still holds behind its back:
15
+ #
16
+ # * plain data (Strings, Arrays, Hashes, Symbols, Integers, classes...)
17
+ # comes back as a deep-frozen COPY, so the app's own objects stay
18
+ # mutable;
19
+ # * a Proc comes back as a shareable COPY, which works only when its
20
+ # self and everything it captures are shareable already (a lambda
21
+ # written in a module body). Ruby never freezes what a Proc captures:
22
+ # a block from an initializer (self is `main`) is kept as is, and
23
+ # works on the main Ractor only;
24
+ # * a Hash keeps its default value or default proc and its
25
+ # compare_by_identity;
26
+ # * any other object (a MessageVerifier, an APM adapter, an app object)
27
+ # is kept as is.
28
+ #
29
+ # Engines without Ractor get the value back unchanged.
30
+ def share(value)
31
+ return value unless defined?(::Ractor)
32
+ return value if ::Ractor.shareable?(value)
33
+
34
+ case value
35
+ when ::Proc then share_proc(value.dup) || value
36
+ when ::Array then seal(value.map { share(it) })
37
+ when ::Hash then share_hash(value)
38
+ when ::String then value.dup.freeze
39
+ else value
40
+ end
41
+ end
42
+
43
+ # Is value shareable (and so readable from any Ractor)? True on an engine
44
+ # without Ractor, where there is only one.
45
+ def shareable?(value) = defined?(::Ractor) ? ::Ractor.shareable?(value) : true
46
+
47
+ # ActiveSupport::Concern keeps its bookkeeping in instance variables of
48
+ # the concern module itself: @_dependencies (the concerns it includes)
49
+ # and the included/prepended block. They are written while the module
50
+ # body loads and only read when a class includes the concern, so they
51
+ # are sealed at the end of the body: the list frozen, the block (whose
52
+ # self is the module, capturing nothing) made shareable.
53
+ def seal_concern(mod)
54
+ dependencies = mod.instance_variable_get(:@_dependencies) if mod.instance_variable_defined?(:@_dependencies)
55
+ dependencies&.freeze
56
+ %i[@_included_block @_prepended_block].each do
57
+ share_proc(mod.instance_variable_get(it)) if mod.instance_variable_defined?(it)
58
+ end
59
+ mod
60
+ end
61
+
62
+ # The Proc made shareable in place, or nil when it cannot be (or there
63
+ # is no Ractor).
64
+ def share_proc(callable)
65
+ return unless defined?(::Ractor)
66
+
67
+ ::Ractor.make_shareable(callable)
68
+ rescue ::Ractor::Error
69
+ nil
70
+ end
71
+
72
+ # A plain Hash copy with every key and value shared, keeping the
73
+ # default (value or proc) and compare_by_identity.
74
+ def share_hash(hash)
75
+ copy = hash.compare_by_identity? ? {}.compare_by_identity : {}
76
+ hash.default_proc ? copy.default_proc = share(hash.default_proc) : copy.default = share(hash.default)
77
+ hash.each { |key, value| copy[share(key)] = share(value) }
78
+ seal(copy)
79
+ end
80
+
81
+ # Frozen; shareable as well when everything inside it is.
82
+ def seal(collection) = collection.freeze
83
+
84
+ private_class_method :share_proc, :share_hash, :seal
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Per-Ractor lazy state (zoolutions/phlex-reactive#364): the home for a
6
+ # process cache that must not live in a constant or a class instance
7
+ # variable, where a non-main Ractor could not read it (a Mutex or a
8
+ # mutable Hash is never Ractor-shareable) and never write it.
9
+ #
10
+ # Store.fetch(:phlex_reactive_thing) { build } # one value per Ractor
11
+ # Store.table(:phlex_reactive_memo).fetch(key) { compute }
12
+ #
13
+ # On an engine with Ractor.store_if_absent (MRI 3.4+, so every Ruby
14
+ # phlex-reactive supports there) this is that: atomic, one value per
15
+ # Ractor. An engine without Ractors (JRuby, TruffleRuby) has only one, so
16
+ # one Mutex-guarded FALLBACK table stands in. Keys are Symbols prefixed
17
+ # with the gem's name. The pattern is conformance-kit's
18
+ # ConformanceKit::Store, copied here because the kit is a development
19
+ # dependency only.
20
+ module Store
21
+ # A Hash guarded by its own Mutex: the per-Ractor memo the threads of one
22
+ # Ractor share. Built inside the Ractor that uses it, so the lock is
23
+ # never shared across Ractors.
24
+ class Table
25
+ def initialize
26
+ @mutex = Mutex.new
27
+ @values = {}
28
+ end
29
+
30
+ # The value under key, computed once with the block when absent.
31
+ def fetch(key)
32
+ @mutex.synchronize { @values.fetch(key) { @values[key] = yield } }
33
+ end
34
+
35
+ # True the first time key is claimed (until #clear), false after: a
36
+ # once-per-Ractor guard, e.g. for a warning.
37
+ def claim?(key)
38
+ @mutex.synchronize { @values.key?(key) ? false : (@values[key] = true) }
39
+ end
40
+
41
+ def clear
42
+ @mutex.synchronize { @values.clear }
43
+ end
44
+ end
45
+
46
+ NATIVE = defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
47
+ # Only an engine without Ractor.store_if_absent builds it: on MRI the
48
+ # module holds no lock and no mutable state at all.
49
+ FALLBACK = (Table.new unless NATIVE)
50
+
51
+ # The table of the once-per-process config warnings (Defer, Pending):
52
+ # once per Ractor, which in practice is the main one.
53
+ WARNINGS_KEY = :phlex_reactive_warnings
54
+
55
+ class << self
56
+ def fetch(key, &init)
57
+ raise ::ArgumentError, "Phlex::Reactive::Store.fetch(#{key.inspect}) needs an initializer block" unless init
58
+ return ::Ractor.store_if_absent(key, &init) if native?
59
+
60
+ FALLBACK.fetch(key, &init)
61
+ end
62
+
63
+ # The current Ractor's Table under key.
64
+ def table(key) = fetch(key) { Table.new }
65
+
66
+ # True the first time the named warning is due in this Ractor.
67
+ def first_warning?(name) = table(WARNINGS_KEY).claim?(name)
68
+
69
+ def native? = NATIVE
70
+ end
71
+ end
72
+ end
73
+ end
@@ -97,22 +97,53 @@ module Phlex
97
97
  # WeakMap used as a set (the class is the key) so a class reloaded by
98
98
  # Zeitwerk in dev is GC'd normally — a strong Array would pin every
99
99
  # reloaded generation and leak across reloads. Guarded by a mutex;
100
- # registration happens at class-load time, the reset iterates under lock.
101
- @registry = ObjectSpace::WeakMap.new
102
- @registry_mutex = Mutex.new
100
+ # registration happens at class-load time.
101
+ #
102
+ # Per-Ractor state (Phlex::Reactive::Store, zoolutions/phlex-reactive#364):
103
+ # a WeakMap and a Mutex are never Ractor-shareable, so in a module ivar no
104
+ # other Ractor could read them. Classes are defined on the main Ractor
105
+ # (boot, a development reload), so in practice this is one registry.
106
+ class ClassRegistry
107
+ def initialize
108
+ @mutex = Mutex.new
109
+ @classes = ObjectSpace::WeakMap.new
110
+ end
111
+
112
+ def add(klass)
113
+ @mutex.synchronize { @classes[klass] = true }
114
+ end
115
+
116
+ # A point-in-time snapshot, taken under the lock.
117
+ def to_a
118
+ @mutex.synchronize do
119
+ classes = []
120
+ @classes.each_key { classes << it }
121
+ classes
122
+ end
123
+ end
124
+ end
125
+
126
+ REGISTRY_KEY = :phlex_reactive_streamable_registry
103
127
 
104
128
  class << self
129
+ # This Ractor's ClassRegistry.
130
+ def class_registry = Phlex::Reactive::Store.fetch(REGISTRY_KEY) { ClassRegistry.new }
131
+
105
132
  # Reset every streamable class's cached view context + builder. Called
106
133
  # from the engine's reloader (config.to_prepare) so a reloaded renderer
107
134
  # controller is never served from a stale memo. No-op outside Rails.
108
135
  def reset_all_view_contexts!
109
- @registry_mutex.synchronize do
110
- @registry.each_key(&:reset_turbo_view_context!)
111
- end
136
+ class_registry.to_a.each(&:reset_turbo_view_context!)
112
137
  end
113
138
 
139
+ # Also starts the class's view-context generation at 0 here, when the
140
+ # class is defined (boot, or a development reload, on the main Ractor),
141
+ # so reading it on the render path never writes the class
142
+ # (zoolutions/phlex-reactive#364).
114
143
  def register(klass)
115
- @registry_mutex.synchronize { @registry[klass] = true }
144
+ klass.instance_variable_set(:@turbo_view_context_generation, 0) unless
145
+ klass.instance_variable_defined?(:@turbo_view_context_generation)
146
+ class_registry.add(klass)
116
147
  end
117
148
 
118
149
  # A point-in-time snapshot of every registered streamable class, taken
@@ -121,13 +152,7 @@ module Phlex
121
152
  # working; a class GC'd later simply won't appear next time. Call
122
153
  # Rails.application.eager_load! FIRST if you need every app class loaded —
123
154
  # the WeakMap only holds classes that have actually been loaded.
124
- def registered_classes
125
- @registry_mutex.synchronize do
126
- classes = []
127
- @registry.each_key { classes << it }
128
- classes
129
- end
130
- end
155
+ def registered_classes = class_registry.to_a
131
156
 
132
157
  # The ONE broadcast implementation shared by the class-level
133
158
  # Streamable.broadcast_to and the module-level Phlex::Reactive.broadcast_to
@@ -423,14 +448,15 @@ module Phlex
423
448
  # this class's generation — each thread rebuilds lazily on its next use,
424
449
  # and entries for the old generation are dropped. Registered on Rails'
425
450
  # reloader by the engine; also used by specs. Thread-safe (an integer
426
- # bump; no shared mutable structure to tear down).
451
+ # bump; no shared mutable structure to tear down), and a class ivar
452
+ # write that only ever runs on the main Ractor: from the engine's
453
+ # to_prepare (boot, a development reload) or a spec.
427
454
  def reset_turbo_view_context!
428
- @turbo_view_context_generation = turbo_view_context_generation + 1
455
+ @turbo_view_context_generation = (turbo_view_context_generation || 0) + 1 # rubocop:disable ThreadSafety/ClassInstanceVariable
429
456
  end
430
457
 
431
- def turbo_view_context_generation
432
- @turbo_view_context_generation ||= 0
433
- end
458
+ # Set to 0 when the class registers (Streamable.register).
459
+ attr_reader :turbo_view_context_generation
434
460
 
435
461
  # Render a component to HTML with a full Rails view context. Uses
436
462
  # phlex-rails' #render_in against our memoized off-request view context
@@ -909,6 +935,10 @@ module Phlex
909
935
  action: "remove", target: id, renders_root: false
910
936
  )
911
937
  end
938
+
939
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
940
+ # the body has loaded (zoolutions/phlex-reactive#364).
941
+ Phlex::Reactive::Shareable.seal_concern(self)
912
942
  end
913
943
  end
914
944
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.5"
5
+ VERSION = "0.16.0"
6
6
  end
7
7
  end