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
@@ -115,44 +115,115 @@ module Phlex
115
115
  # * request — the ActionDispatch::Request (remote_ip, headers, ...)
116
116
  ActionContext = Data.define(:component, :action_name, :params, :request)
117
117
 
118
+ # What each setting reads before an app sets it, and after it is set to
119
+ # nil (zoolutions/phlex-reactive#364). Stored when the gem loads (the
120
+ # bottom of this module), so no reader writes on first use: a non-main
121
+ # Ractor can read a module instance variable but never write one.
122
+ SETTING_DEFAULTS = {
123
+ action_path: "/reactive/actions",
124
+ base_controller_name: "ActionController::Base",
125
+ early_event_ttl_ms: 10_000,
126
+ defer_token_ttl: 120,
127
+ defer_path: "/reactive/defer",
128
+ fragment_path: "/reactive/fragment",
129
+ fragment_cache_max_age_limit: 3600,
130
+ defer_transport: :auto,
131
+ defer_job_queue: "default",
132
+ settle_coalesce_window_ms: 50,
133
+ flash_target: "flash",
134
+ authorization_errors: [].freeze,
135
+ importmaps: nil,
136
+ apm: nil,
137
+ resolved_apm_adapter: nil,
138
+ effects: nil,
139
+ effects_generation: 0,
140
+ error_flash: nil,
141
+ flash_component: nil,
142
+ off_request_view_context_generation: 0,
143
+ around_actions: [].freeze,
144
+ on_action_error_hooks: [].freeze,
145
+ token_upgraders: {}.freeze,
146
+ param_types_frozen: false,
147
+ param_schemas_frozen: false,
148
+ finalized: false
149
+ }.freeze
150
+
151
+ # authorization_methods' default: the common set across authorization
152
+ # libraries (see authorization_methods below).
153
+ DEFAULT_AUTHORIZATION_METHODS = %i[authorize! authorize allowed_to?].freeze
154
+
155
+ # Every configuration reader whose value finalize! makes Ractor-shareable
156
+ # (the verifier aside: an ActiveSupport::MessageVerifier never is). The
157
+ # doctor's ractor readiness check and the finalize spec read this list.
158
+ CONFIG_READERS = %i[
159
+ renderer base_controller_name authorization_errors importmaps action_path verbose_errors
160
+ log_events apm debug verify_authorized authorization_methods param_types param_types_frozen?
161
+ param_schemas param_schemas_frozen? around_actions on_action_error_hooks resolved_apm_adapter
162
+ early_event_ttl_ms defer_token_ttl defer_path fragment_path fragment_cache_max_age_limit
163
+ defer_transport defer_job_queue settle_coalesce_window_ms flash_target effects effects_generation
164
+ error_flash flash_component off_request_view_context_generation token_upgraders finalized?
165
+ ].freeze
166
+
118
167
  class << self
119
168
  # The message verifier used to sign/verify component identity tokens.
120
169
  # Defaults to a purpose-scoped verifier derived from secret_key_base.
121
170
  # Override to use a dedicated key.
122
- attr_writer :verifier
171
+ def verifier=(value)
172
+ set_setting(:@verifier, value)
173
+ end
123
174
 
124
175
  # The controller class used to render components with a full Rails view
125
176
  # context (url helpers, CSRF, i18n) during re-renders and broadcasts.
126
177
  # Defaults to ActionController::Base. Set to your ApplicationController if
127
178
  # components rely on app-level helpers or Current attributes.
128
- attr_writer :renderer
179
+ #
180
+ # Give it by name ("ApplicationController", like base_controller_name)
181
+ # to have it follow a code reload in development: the name resolves on
182
+ # every read, so a reloaded class is picked up (and the per-thread view
183
+ # contexts rebuild for it). A Class is kept as that class object, which
184
+ # a reload leaves stale.
185
+ def renderer=(value)
186
+ set_setting(:@renderer, value)
187
+ end
129
188
 
130
189
  # The controller the reactive ActionsController inherits from, given as a
131
190
  # String (resolved lazily to avoid load-order issues). Defaults to
132
191
  # "ActionController::Base"; set to "ApplicationController" to inherit your
133
192
  # app's auth/CSRF/Current. If you do, ensure the action path isn't
134
193
  # force-redirected for logged-out users when you have public components.
135
- attr_writer :base_controller_name
194
+ def base_controller_name=(value)
195
+ set_setting(:@base_controller_name, value || SETTING_DEFAULTS[:base_controller_name])
196
+ end
136
197
 
137
198
  # Exception classes the action endpoint renders as 403. Append your
138
199
  # authorization library's error (Pundit::NotAuthorizedError,
139
- # ActionPolicy::Unauthorized, ...).
140
- attr_accessor :authorization_errors
200
+ # ActionPolicy::Unauthorized, ...) in an initializer, or assign the
201
+ # whole list. The list is frozen once boot is over (finalize!): after
202
+ # that, assign a new list instead of appending.
203
+ attr_reader :authorization_errors
204
+
205
+ def authorization_errors=(value)
206
+ set_setting(:@authorization_errors, value)
207
+ end
141
208
 
142
209
  # Import maps `phlex_reactive:doctor` checks for the `phlex/reactive/early`
143
210
  # pin (issue #307): a callable returning `{ name => Importmap::Map }`, for
144
211
  # an app with more than one map (one per entry point). nil (the default)
145
212
  # checks `Rails.application.importmap` only.
146
- attr_accessor :importmaps
213
+ attr_reader :importmaps
214
+
215
+ def importmaps=(value)
216
+ set_setting(:@importmaps, value)
217
+ end
147
218
 
148
219
  # The path the action endpoint is mounted at. Default "/reactive/actions".
149
220
  # Set before boot if it collides with an app route. The client runtime
150
221
  # reads it from a <meta name="phlex-reactive-action-path"> tag if present,
151
222
  # falling back to this default.
152
- attr_writer :action_path
223
+ attr_reader :action_path
153
224
 
154
- def action_path
155
- @action_path ||= "/reactive/actions"
225
+ def action_path=(value)
226
+ set_setting(:@action_path, value || SETTING_DEFAULTS[:action_path])
156
227
  end
157
228
 
158
229
  # Diagnostic endpoint error bodies + dropped-param logging. When true, an
@@ -164,12 +235,12 @@ module Phlex
164
235
  # Defaults LAZILY to Rails.env.local? — that's development AND test — so
165
236
  # production stays opaque unless you opt in. The `defined?` guard (not
166
237
  # `||=`) makes an explicit `= false` stick even in dev/test.
167
- attr_writer :verbose_errors
238
+ def verbose_errors=(value)
239
+ set_setting(:@verbose_errors, value)
240
+ end
168
241
 
169
242
  def verbose_errors
170
- return @verbose_errors if defined?(@verbose_errors)
171
-
172
- defined?(::Rails.env) && ::Rails.env.local?
243
+ setting(:@verbose_errors) { defined?(::Rails.env) && ::Rails.env.local? }
173
244
  end
174
245
 
175
246
  # Attach the opt-in LogSubscriber (issue #107) so each hot-path event is
@@ -177,12 +248,12 @@ module Phlex
177
248
  # (3.1ms)`). Default OFF — the events still fire for APMs regardless; this
178
249
  # only controls the gem's own log lines. The engine reads it at boot and
179
250
  # attaches exactly once. Set true in an initializer to see the lines.
180
- attr_writer :log_events
251
+ def log_events=(value)
252
+ set_setting(:@log_events, value)
253
+ end
181
254
 
182
255
  def log_events
183
- return @log_events if defined?(@log_events)
184
-
185
- false
256
+ setting(:@log_events) { false }
186
257
  end
187
258
 
188
259
  # Turnkey APM integration (issue #207). Set to a Symbol (:appsignal,
@@ -195,7 +266,11 @@ module Phlex
195
266
  # action is named `Component#action` in the APM (not one blurry
196
267
  # ActionsController#create), and an action-body error is reported to the
197
268
  # tracker with component/action tags. Default nil.
198
- attr_accessor :apm
269
+ attr_reader :apm
270
+
271
+ def apm=(value)
272
+ set_setting(:@apm, value)
273
+ end
199
274
 
200
275
  # Client debug mode (issue #108): the "devtools-lite" lens on the reactive
201
276
  # round trip. When true, reactive_attrs (and so reactive_root) stamps
@@ -207,12 +282,12 @@ module Phlex
207
282
  # Rails.env.development?); zero cost when off (one nil-check per dispatch, no
208
283
  # attr, no string building). The `defined?` guard (not `||=`) makes an
209
284
  # explicit `= false` stick even where a truthy default might otherwise apply.
210
- attr_writer :debug
285
+ def debug=(value)
286
+ set_setting(:@debug, value)
287
+ end
211
288
 
212
289
  def debug
213
- return @debug if defined?(@debug)
214
-
215
- false
290
+ setting(:@debug) { false }
216
291
  end
217
292
 
218
293
  # verify_authorized (issue #168): the default-ON runtime guard. When true,
@@ -223,12 +298,12 @@ module Phlex
223
298
  # component with `skip_verify_authorized`, mark a bespoke check with
224
299
  # `mark_authorized!`, or turn it off globally here. The `defined?` guard
225
300
  # (not `||=`) makes an explicit `= false` stick.
226
- attr_writer :verify_authorized
301
+ def verify_authorized=(value)
302
+ set_setting(:@verify_authorized, value)
303
+ end
227
304
 
228
305
  def verify_authorized
229
- return @verify_authorized if defined?(@verify_authorized)
230
-
231
- true
306
+ setting(:@verify_authorized) { true }
232
307
  end
233
308
 
234
309
  # The method names verify_authorized's interceptor wraps to detect an
@@ -238,12 +313,12 @@ module Phlex
238
313
  # your app's helper. `mark_authorized!` always counts, regardless of this
239
314
  # list. The `defined?` guard makes an explicit override (including a
240
315
  # narrower list) stick.
241
- attr_writer :authorization_methods
316
+ def authorization_methods=(value)
317
+ set_setting(:@authorization_methods, value)
318
+ end
242
319
 
243
320
  def authorization_methods
244
- return @authorization_methods if defined?(@authorization_methods)
245
-
246
- %i[authorize! authorize allowed_to?]
321
+ setting(:@authorization_methods) { DEFAULT_AUTHORIZATION_METHODS }
247
322
  end
248
323
 
249
324
  # Register an app-defined param type (issue #109). The block receives the
@@ -272,11 +347,11 @@ module Phlex
272
347
  end
273
348
 
274
349
  # The param-type registry: Symbol => callable(value) -> coerced | DROP.
275
- # Seeded lazily with the built-ins; ParamSchema.compile validates every
276
- # declared type symbol against it, and #coerce dispatches through it.
277
- def param_types
278
- @param_types ||= Phlex::Reactive::ParamSchema.built_in_types.dup
279
- end
350
+ # Seeded with the built-ins when the gem loads (the bottom of this file);
351
+ # ParamSchema.compile validates every declared type symbol against it,
352
+ # and #coerce dispatches through it once per scalar param, so it is a
353
+ # plain attr_reader.
354
+ attr_reader :param_types
280
355
 
281
356
  # A declared type symbol is known iff it's a registry key. ParamSchema
282
357
  # asks this at compile so an unknown symbol raises loudly at declaration.
@@ -289,18 +364,16 @@ module Phlex
289
364
  # Idempotent.
290
365
  def freeze_param_types!
291
366
  param_types.freeze
292
- @param_types_frozen = true
367
+ set_setting(:@param_types_frozen, true)
293
368
  end
294
369
 
295
- def param_types_frozen?
296
- @param_types_frozen ||= false
297
- end
370
+ def param_types_frozen? = setting(:@param_types_frozen) == true
298
371
 
299
372
  # Drop the registry back to the built-ins and unfreeze it. For tests that
300
373
  # register a temporary type; never called in production.
301
374
  def reset_param_types!
302
- @param_types = Phlex::Reactive::ParamSchema.built_in_types.dup
303
- @param_types_frozen = false
375
+ set_setting(:@param_types, Phlex::Reactive::ParamSchema.built_in_types.dup, share: false)
376
+ set_setting(:@param_types_frozen, false)
304
377
  end
305
378
 
306
379
  # Register (with a schema Hash) OR read (bare name) a NAMED param schema
@@ -327,9 +400,8 @@ module Phlex
327
400
  param_schemas[name.to_sym] = deep_freeze_schema(schema.transform_keys(&:to_sym))
328
401
  end
329
402
 
330
- def param_schemas
331
- @param_schemas ||= {}
332
- end
403
+ # The registered named schemas: Symbol => deep-frozen schema Hash.
404
+ attr_reader :param_schemas
333
405
 
334
406
  def param_schema?(name)
335
407
  param_schemas.key?(name.to_sym)
@@ -337,17 +409,15 @@ module Phlex
337
409
 
338
410
  def freeze_param_schemas!
339
411
  param_schemas.freeze
340
- @param_schemas_frozen = true
412
+ set_setting(:@param_schemas_frozen, true)
341
413
  end
342
414
 
343
- def param_schemas_frozen?
344
- @param_schemas_frozen ||= false
345
- end
415
+ def param_schemas_frozen? = setting(:@param_schemas_frozen) == true
346
416
 
347
417
  # Drop the named-schema registry and unfreeze it (tests only).
348
418
  def reset_param_schemas!
349
- @param_schemas = {}
350
- @param_schemas_frozen = false
419
+ set_setting(:@param_schemas, {}, share: false)
420
+ set_setting(:@param_schemas_frozen, false)
351
421
  end
352
422
 
353
423
  private
@@ -413,21 +483,20 @@ module Phlex
413
483
  def around_action(&block)
414
484
  raise ::ArgumentError, "Phlex::Reactive.around_action requires a block" unless block
415
485
 
416
- around_actions << block
486
+ set_setting(:@around_actions, [*around_actions, block].freeze)
417
487
  end
418
488
 
419
- # The registered around_action wrappers, oldest first. The endpoint folds
420
- # them so the last-registered runs outermost; an empty stack is the default
421
- # hot path (the controller short-circuits with `return yield`).
422
- def around_actions
423
- @around_actions ||= []
424
- end
489
+ # The registered around_action wrappers, oldest first (a frozen list,
490
+ # replaced whole on each registration). The endpoint folds them so the
491
+ # last-registered runs outermost; an empty stack is the default hot path
492
+ # (the controller short-circuits with `return yield`).
493
+ attr_reader :around_actions
425
494
 
426
495
  # Drop all registered around_action wrappers. For test isolation (the
427
496
  # shipped hook — specs registering a temporary wrapper reset around every
428
497
  # example); never called in production.
429
498
  def reset_around_actions!
430
- @around_actions = []
499
+ set_setting(:@around_actions, SETTING_DEFAULTS[:around_actions])
431
500
  end
432
501
 
433
502
  # Register a block called when a reactive action body raises a
@@ -444,17 +513,15 @@ module Phlex
444
513
  def on_action_error(&block)
445
514
  raise ::ArgumentError, "Phlex::Reactive.on_action_error requires a block" unless block
446
515
 
447
- on_action_error_hooks << block
516
+ set_setting(:@on_action_error_hooks, [*on_action_error_hooks, block].freeze)
448
517
  end
449
518
 
450
- # The registered on_action_error hooks, oldest first.
451
- def on_action_error_hooks
452
- @on_action_error_hooks ||= []
453
- end
519
+ # The registered on_action_error hooks, oldest first (a frozen list).
520
+ attr_reader :on_action_error_hooks
454
521
 
455
522
  # Drop all registered on_action_error hooks. Test isolation; never in production.
456
523
  def reset_on_action_error!
457
- @on_action_error_hooks = []
524
+ set_setting(:@on_action_error_hooks, SETTING_DEFAULTS[:on_action_error_hooks])
458
525
  end
459
526
 
460
527
  # The name-only keys forwarded to error reporters (issue #207). Deliberately
@@ -475,7 +542,7 @@ module Phlex
475
542
  # side-effect only: the return value is ignored.
476
543
  def report_error(error, context)
477
544
  snapshot = context.slice(*ERROR_CONTEXT_KEYS)
478
- adapter = @resolved_apm_adapter
545
+ adapter = resolved_apm_adapter
479
546
  # Each reporter runs through safely_report on its OWN — one broken reporter
480
547
  # (a raising adapter or hook) never prevents the others, and never turns one
481
548
  # 500 into a different 500. safely_report takes the reporter as a block arg,
@@ -494,14 +561,29 @@ module Phlex
494
561
  # The APM adapter resolved at engine attach time (issue #207), or nil. Held so
495
562
  # report_error can reach record_error without re-running detection per request.
496
563
  # Set by APM.attach!; nil when no APM is configured or the SDK was absent.
497
- attr_accessor :resolved_apm_adapter
564
+ attr_reader :resolved_apm_adapter
565
+
566
+ def resolved_apm_adapter=(value)
567
+ set_setting(:@resolved_apm_adapter, value)
568
+ end
498
569
 
570
+ # Built on first use (it needs Rails.application); finalize! builds it at
571
+ # boot. An ActiveSupport::MessageVerifier is never Ractor-shareable, and
572
+ # ActiveSupport cannot build or use one outside the main Ractor, so
573
+ # signing stays on the main Ractor (an ActiveSupport limit).
499
574
  def verifier
500
- @verifier ||= default_verifier
575
+ setting(:@verifier) || set_setting(:@verifier, default_verifier)
501
576
  end
502
577
 
578
+ # ActionController::Base unless an app set one; resolved on each read
579
+ # until finalize! stores it, so reading never writes. A name resolves on
580
+ # every read (see renderer=).
503
581
  def renderer
504
- @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
582
+ case (value = setting(:@renderer))
583
+ when nil then defined?(::ActionController::Base) ? ::ActionController::Base : nil
584
+ when ::String then ::ActiveSupport::Inflector.constantize(value)
585
+ else value
586
+ end
505
587
  end
506
588
 
507
589
  # Build an off-request view context whose controller has a REAL `request`.
@@ -560,12 +642,11 @@ module Phlex
560
642
  raise ArgumentError, "early_event_ttl_ms must be a positive Integer (milliseconds) or nil, got #{value.inspect}"
561
643
  end
562
644
 
563
- @early_event_ttl_ms = value
645
+ set_setting(:@early_event_ttl_ms, value || SETTING_DEFAULTS[:early_event_ttl_ms])
564
646
  end
565
647
 
566
- def early_event_ttl_ms
567
- @early_event_ttl_ms ||= 10_000
568
- end
648
+ # The replay window, in milliseconds (default 10 s).
649
+ attr_reader :early_event_ttl_ms
569
650
 
570
651
  # --- Deferred reply segments (issue #165) --------------------------
571
652
 
@@ -573,18 +654,18 @@ module Phlex
573
654
  # reply→fetch gap (or reply→job→SSE on the push lane), so it stays short:
574
655
  # a leaked defer token is a render oracle for exactly one component
575
656
  # identity until this expires. nil resets to the default.
576
- attr_writer :defer_token_ttl
657
+ attr_reader :defer_token_ttl
577
658
 
578
- def defer_token_ttl
579
- @defer_token_ttl ||= 120
659
+ def defer_token_ttl=(value)
660
+ set_setting(:@defer_token_ttl, value || SETTING_DEFAULTS[:defer_token_ttl])
580
661
  end
581
662
 
582
663
  # The path the defer endpoint is mounted at (the pull lane's target).
583
664
  # Default "/reactive/defer"; set before boot if it collides.
584
- attr_writer :defer_path
665
+ attr_reader :defer_path
585
666
 
586
- def defer_path
587
- @defer_path ||= "/reactive/defer"
667
+ def defer_path=(value)
668
+ set_setting(:@defer_path, value || SETTING_DEFAULTS[:defer_path])
588
669
  end
589
670
 
590
671
  # The path prefix of the cacheable-fragment GET endpoint (issue #277):
@@ -592,10 +673,10 @@ module Phlex
592
673
  # before boot if it collides. The shell renders the full URL, but the
593
674
  # client only fetches one under the path it knows: when you change this,
594
675
  # also render <meta name="phlex-reactive-fragment-path" content="…">.
595
- attr_writer :fragment_path
676
+ attr_reader :fragment_path
596
677
 
597
- def fragment_path
598
- @fragment_path ||= "/reactive/fragment"
678
+ def fragment_path=(value)
679
+ set_setting(:@fragment_path, value || SETTING_DEFAULTS[:fragment_path])
599
680
  end
600
681
 
601
682
  # The longest max-age (seconds) a `reactive_lazy cache:` component may
@@ -612,12 +693,11 @@ module Phlex
612
693
  "(or nil for the default) — got #{value.inspect}"
613
694
  end
614
695
 
615
- @fragment_cache_max_age_limit = seconds
696
+ set_setting(:@fragment_cache_max_age_limit, seconds || SETTING_DEFAULTS[:fragment_cache_max_age_limit])
616
697
  end
617
698
 
618
- def fragment_cache_max_age_limit
619
- @fragment_cache_max_age_limit ||= 3600
620
- end
699
+ # The max-age cap, in seconds (default 3600).
700
+ attr_reader :fragment_cache_max_age_limit
621
701
 
622
702
  # The deterministic, no-expiry, purpose-scoped fragment id of an identity
623
703
  # payload, and its verification (issue #277). See Phlex::Reactive::Fragment.
@@ -630,9 +710,7 @@ module Phlex
630
710
  # warning when the capability is absent). Validated at assignment — a
631
711
  # typo'd transport must fail at the initializer, not silently at reply
632
712
  # time. nil resets to the default.
633
- def defer_transport
634
- @defer_transport ||= :auto
635
- end
713
+ attr_reader :defer_transport
636
714
 
637
715
  def defer_transport=(value)
638
716
  value = value&.to_sym
@@ -642,17 +720,17 @@ module Phlex
642
720
  "(got #{value.inspect})"
643
721
  end
644
722
 
645
- @defer_transport = value
723
+ set_setting(:@defer_transport, value || SETTING_DEFAULTS[:defer_transport])
646
724
  end
647
725
 
648
726
  # The ActiveJob queue the push lane's DeferredRenderJob runs on. Default
649
727
  # "default"; point it at a fast queue in production — a deferred segment
650
728
  # is a UX-latency render, and letting it starve behind heavy jobs defeats
651
729
  # the point. nil resets to the default.
652
- attr_writer :defer_job_queue
730
+ attr_reader :defer_job_queue
653
731
 
654
- def defer_job_queue
655
- @defer_job_queue ||= "default"
732
+ def defer_job_queue=(value)
733
+ set_setting(:@defer_job_queue, value || SETTING_DEFAULTS[:defer_job_queue])
656
734
  end
657
735
 
658
736
  # Signs a defer payload (issue #165): same shape + version stamp as the
@@ -802,10 +880,10 @@ module Phlex
802
880
  # a handful of them instead of 177. The ROW streams are never coalesced.
803
881
  # Needs pgbus with zoolutions/pgbus#465 on the peers path; without it the
804
882
  # window is simply ignored. nil resets to the default.
805
- attr_writer :settle_coalesce_window_ms
883
+ attr_reader :settle_coalesce_window_ms
806
884
 
807
- def settle_coalesce_window_ms
808
- @settle_coalesce_window_ms ||= 50
885
+ def settle_coalesce_window_ms=(value)
886
+ set_setting(:@settle_coalesce_window_ms, value || SETTING_DEFAULTS[:settle_coalesce_window_ms])
809
887
  end
810
888
 
811
889
  # Can reply.pending mint a settle handle at all? A settle has NO pull
@@ -822,11 +900,11 @@ module Phlex
822
900
 
823
901
  # DOM id of the host-app container a Response#flash appends into.
824
902
  # Default "flash"; override to match your layout's flash region.
825
- def flash_target
826
- @flash_target ||= "flash"
827
- end
903
+ attr_reader :flash_target
828
904
 
829
- attr_writer :flash_target
905
+ def flash_target=(value)
906
+ set_setting(:@flash_target, value || SETTING_DEFAULTS[:flash_target])
907
+ end
830
908
 
831
909
  # Global effects opt-in (issue #215). nil (the default) = OFF: no root
832
910
  # attrs are emitted anywhere, the wire is byte-identical to pre-effects,
@@ -839,21 +917,17 @@ module Phlex
839
917
  #
840
918
  # Stored pre-normalized (wire strings), validated at WRITE time — a
841
919
  # typo'd effect name raises here, not at render time.
842
- def effects
843
- defined?(@effects) ? @effects : nil
844
- end
920
+ attr_reader :effects
845
921
 
846
922
  def effects=(value)
847
- @effects = Phlex::Reactive::Effects.normalize_config(value)
848
- @effects_generation = effects_generation + 1
923
+ set_setting(:@effects, Phlex::Reactive::Effects.normalize_config(value))
924
+ set_setting(:@effects_generation, effects_generation + 1)
849
925
  end
850
926
 
851
927
  # Monotonic write counter for effects= — the cache key half that lets
852
928
  # each component class memoize its RESOLVED effect attrs (reactive_attrs
853
929
  # is the token-signing hot path) without ever serving a stale config.
854
- def effects_generation
855
- @effects_generation ||= 0
856
- end
930
+ attr_reader :effects_generation
857
931
 
858
932
  # A user-visible flash rendered on every endpoint rescue path (issue #100).
859
933
  # Default nil = today's behavior (a bare head, or the verbose_errors
@@ -863,7 +937,11 @@ module Phlex
863
937
  # — at the SAME status it returns today (statuses never change). Composes
864
938
  # with verbose_errors: the turbo-stream flash wins the response body, the
865
939
  # diagnostic still goes to the log.
866
- attr_accessor :error_flash
940
+ attr_reader :error_flash
941
+
942
+ def error_flash=(value)
943
+ set_setting(:@error_flash, value)
944
+ end
867
945
 
868
946
  # A CALLABLE that builds a flash component from STRING flash content (issue
869
947
  # #182). Given (level, content), it returns a Phlex component the gem renders
@@ -886,7 +964,7 @@ module Phlex
886
964
  "flash_component = ->(level, content) { #{callable}.new(level:, content:) }"
887
965
  end
888
966
 
889
- @flash_component = callable
967
+ set_setting(:@flash_component, callable)
890
968
  end
891
969
 
892
970
  # Render a Phlex component to HTML with a full (off-request) view context.
@@ -960,7 +1038,7 @@ module Phlex
960
1038
  # reset_flash_builder! (issue #113); the old name stays a permanent alias
961
1039
  # below so the engine's to_prepare hook keeps working.
962
1040
  def reset_stream_builder!
963
- @off_request_view_context_generation = off_request_view_context_generation + 1
1041
+ set_setting(:@off_request_view_context_generation, off_request_view_context_generation + 1)
964
1042
  end
965
1043
 
966
1044
  # Issue #182: the pre-#113 aliases are removed (they contradicted the
@@ -976,9 +1054,8 @@ module Phlex
976
1054
  "Phlex::Reactive.reset_flash_builder! was removed in issue #182 — use reset_stream_builder!"
977
1055
  end
978
1056
 
979
- def off_request_view_context_generation
980
- @off_request_view_context_generation ||= 0
981
- end
1057
+ # The counter reset_stream_builder! bumps; the per-thread caches compare it.
1058
+ attr_reader :off_request_view_context_generation
982
1059
 
983
1060
  private
984
1061
 
@@ -1002,9 +1079,8 @@ module Phlex
1002
1079
 
1003
1080
  public
1004
1081
 
1005
- def base_controller_name
1006
- @base_controller_name ||= "ActionController::Base"
1007
- end
1082
+ # The base controller's name (see base_controller_name= above).
1083
+ attr_reader :base_controller_name
1008
1084
 
1009
1085
  def base_controller
1010
1086
  base_controller_name.constantize
@@ -1061,18 +1137,18 @@ module Phlex
1061
1137
  def register_token_upgrader(from_version, &block)
1062
1138
  raise ::ArgumentError, "register_token_upgrader requires a block" unless block
1063
1139
 
1064
- token_upgraders[Integer(from_version)] = block
1140
+ set_setting(:@token_upgraders, token_upgraders.merge(Integer(from_version) => block).freeze)
1141
+ block
1065
1142
  end
1066
1143
 
1067
1144
  # from_version => callable(payload) -> migrated payload. Sparse: an entry
1068
- # exists only for a version that actually changed shape.
1069
- def token_upgraders
1070
- @token_upgraders ||= {}
1071
- end
1145
+ # exists only for a version that actually changed shape. A frozen Hash,
1146
+ # replaced whole on each registration.
1147
+ attr_reader :token_upgraders
1072
1148
 
1073
1149
  # Drop all registered upgraders. For tests that register a temporary one.
1074
1150
  def reset_token_upgraders!
1075
- @token_upgraders = {}
1151
+ set_setting(:@token_upgraders, SETTING_DEFAULTS[:token_upgraders])
1076
1152
  end
1077
1153
 
1078
1154
  # The acting client's SSE connection id during an action, or nil. Set by
@@ -1126,6 +1202,47 @@ module Phlex
1126
1202
  { protocol: request.protocol, host: request.host, port: request.optional_port }
1127
1203
  end
1128
1204
 
1205
+ # --- Boot finalization (zoolutions/phlex-reactive#364) --------------
1206
+
1207
+ # Finalize the configuration once boot is over. The engine calls it
1208
+ # after every initializer AND every after_initialize block of the app has
1209
+ # run; an app booting phlex-reactive without the engine calls it at the
1210
+ # end of its own boot.
1211
+ #
1212
+ # It builds the one lazy default (the verifier) and resolves the rest,
1213
+ # freezes the param-type and named-schema registries, and stores every
1214
+ # setting as a Ractor-shareable value (Shareable.share: deep-frozen
1215
+ # COPIES, so the objects an app handed in are never frozen behind its
1216
+ # back), so a non-main Ractor can read the configuration. What cannot
1217
+ # be shared stays as it is and works on the main Ractor only: the
1218
+ # verifier (an ActiveSupport::MessageVerifier), and a Proc whose self is
1219
+ # not shareable (a block written in an initializer) — the
1220
+ # phlex_reactive:doctor ractor readiness check lists them.
1221
+ #
1222
+ # Writers keep working afterwards (a test suite sets config per
1223
+ # example) and what they write is shared too, but the lists are frozen:
1224
+ # assign a new authorization_errors list rather than appending to it.
1225
+ # Idempotent.
1226
+ def finalize!
1227
+ build_lazy_defaults
1228
+ freeze_param_types!
1229
+ freeze_param_schemas!
1230
+ set_setting(:@finalized, true)
1231
+ instance_variables.each { set_setting(it, Shareable.share(setting(it))) }
1232
+ self
1233
+ end
1234
+
1235
+ def finalized? = setting(:@finalized) == true
1236
+
1237
+ # Back to the configuration before finalize! (tests only): finalized?
1238
+ # is false and authorization_errors can be appended to again. The
1239
+ # param-type and schema registries have their own resets.
1240
+ def reset_finalization!
1241
+ set_setting(:@finalized, false)
1242
+ set_setting(:@authorization_errors, authorization_errors.dup, share: false)
1243
+ self
1244
+ end
1245
+
1129
1246
  # The controller a correctly-mounted action path resolves to. Used by the
1130
1247
  # route guard below.
1131
1248
  ACTIONS_CONTROLLER = "phlex/reactive/actions"
@@ -1181,6 +1298,49 @@ module Phlex
1181
1298
 
1182
1299
  private
1183
1300
 
1301
+ # Every lazy reader, built and stored, so that after boot each one is a
1302
+ # plain read of a stored value.
1303
+ def build_lazy_defaults
1304
+ build_verifier
1305
+ set_setting(:@renderer, renderer) if setting(:@renderer).nil?
1306
+ set_setting(:@verbose_errors, verbose_errors)
1307
+ set_setting(:@log_events, log_events)
1308
+ set_setting(:@debug, debug)
1309
+ set_setting(:@verify_authorized, verify_authorized)
1310
+ set_setting(:@authorization_methods, authorization_methods)
1311
+ end
1312
+
1313
+ # The verifier needs either one set explicitly or a Rails application;
1314
+ # without both it stays lazy (and raises its guided error on first use,
1315
+ # as before). So does an app that boots without a secret_key_base (a
1316
+ # production rake task or build step): Rails raises ArgumentError for
1317
+ # it, and boot must not, so the first token raises it instead.
1318
+ def build_verifier
1319
+ return unless setting(:@verifier) || (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application)
1320
+
1321
+ verifier
1322
+ rescue ::ArgumentError
1323
+ nil
1324
+ end
1325
+
1326
+ # --- Settings storage (zoolutions/phlex-reactive#364) ---------------
1327
+ # Every setting is an instance variable of this module, read and written
1328
+ # only through these two methods. Safe: settings are written at boot, by
1329
+ # the app's initializers on the main Ractor before it serves a request
1330
+ # (and between examples in a test suite); a non-main Ractor can never
1331
+ # write a module instance variable at all. After finalize! every value
1332
+ # is Ractor-shareable (or frozen, when it holds an app's Proc) and every
1333
+ # read is a plain read; a write after it is shared the same way, except
1334
+ # the test-only resets of the registries (share: false), which reopen
1335
+ # them.
1336
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
1337
+ def setting(ivar) = instance_variable_defined?(ivar) ? instance_variable_get(ivar) : (yield if block_given?)
1338
+
1339
+ def set_setting(ivar, value, share: true)
1340
+ instance_variable_set(ivar, share && setting(:@finalized) == true ? Shareable.share(value) : value)
1341
+ end
1342
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
1343
+
1184
1344
  # Materialize the route set if it hasn't been drawn yet (the engine appends
1185
1345
  # POST /reactive/actions when routes are drawn, which may be after the boot
1186
1346
  # guard's after_initialize). Idempotent; tolerant of older Rails.
@@ -1238,7 +1398,14 @@ module Phlex
1238
1398
  end
1239
1399
  end
1240
1400
 
1401
+ # Every setting starts at its default as the gem loads, so no reader
1402
+ # writes on first use (the one lazy setting is the verifier, which needs
1403
+ # Rails.application; finalize! builds it at boot; the param-type registry
1404
+ # is seeded below the loader). The authorization_errors list stays open
1405
+ # for appending until finalize!.
1406
+ SETTING_DEFAULTS.each { |name, value| instance_variable_set(:"@#{name}", value) }
1241
1407
  self.authorization_errors = []
1408
+ reset_param_schemas!
1242
1409
  end
1243
1410
  end
1244
1411
 
@@ -1295,4 +1462,8 @@ loader.do_not_eager_load("#{__dir__}/reactive/deferred_render_job.rb")
1295
1462
  loader.do_not_eager_load("#{__dir__}/reactive/presence_render_job.rb")
1296
1463
  loader.setup
1297
1464
 
1465
+ # The param-type registry's built-ins (ParamSchema needs the loader, and loads
1466
+ # ActiveModel, which the engine's boot needed already).
1467
+ Phlex::Reactive.reset_param_types!
1468
+
1298
1469
  require "phlex/reactive/engine" if defined?(Rails::Engine)