phlex-forms 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9b781532660ae609037596969274d95acbb9eac449c60475c51a91329a9fcb5
4
- data.tar.gz: e242d6ecb20a6eb2238b5a2dfb1bb86bd68ad5f2b7a783bdb9b23e2d6cc1c42c
3
+ metadata.gz: b43b29cfcccda75c6a0af1c888d3edd50b824f3c393c9938890355143d787231
4
+ data.tar.gz: 8b231701048287d17a7e44b004f6389f5d760e2b5e36750c90f2416dcbfdca89
5
5
  SHA512:
6
- metadata.gz: c9bbc43663fb51f9e8f414702a4aaf7349870ca23f854ce3b9b38220dc32abe42f77820ab840a78708c473d5e30bbb9f918f6ae39025d94b5e94103e9b2ae3de
7
- data.tar.gz: 9435b6699cab6da09d974077c8b2ff4f6f83d4f28b0cc44d2fd56419fea1b5bca2cb7347ef86b16a6ede417fddda62ce2d4c03c1e02d9e2ae58b315272701969
6
+ metadata.gz: 04f12370599fbae631ac6e88345a7e412eb8f0a143eaa20e76978d8e584aeab6d9657b182e8ac7a424b9624b9df7c362a4008a922c586d4669652fa3da67fa60
7
+ data.tar.gz: 802635cc20851fbade99760b38c05d6e3e8fb1a67433fedbcb59ff5318e2ef21fb66d202c7e6f0534c530005dfb468c9e4dc67a01fce5b69b3e5a6c8929db18d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0]
11
+
12
+ ### Upgrading to 0.5
13
+
14
+ The configuration is frozen once the app has booted, so threads and fibers
15
+ read one immutable value, and so can Ractors when every setting can be shared
16
+ (#42, conformance-kit; see the unshareable-value note below).
17
+
18
+ - **It finalizes on its own.** Under Rails the engine calls
19
+ `PhlexForms.finalize!` in `after_initialize`, after every initializer has
20
+ run, with no app code. Without Rails, `PhlexForms.configure` finalizes when
21
+ its block returns; a host that never calls `configure` (or loads Rails
22
+ without booting an app) calls `PhlexForms.finalize!` at boot.
23
+ - **A direct write after boot raises `FrozenError`.** `PhlexForms.config.infer_from_model = false`
24
+ in a Rails initializer still works. After boot (a test's `before` block, or
25
+ after `configure` outside Rails), write through `PhlexForms.configure`
26
+ instead: it edits a copy and swaps it in.
27
+ - **Finalizing never raises over a value it cannot share.** The value is kept
28
+ as given and works on the main Ractor only: an icon renderer lambda at the
29
+ top level of an initializer (its `self` is `main`, and it may close over a
30
+ logger) is one. Define it in a class or module body (or with
31
+ `Ractor.shareable_lambda` on Ruby 4.0) to keep the whole configuration
32
+ shareable. A renderer that is not a Proc (an object with `#call`, a
33
+ `Method`) is never copied or frozen, so one that keeps a cache still works.
34
+ - **`form_options` and the `live` macros keep frozen copies** of what they are
35
+ given, so the values nested in `form_defaults` and the live settings are
36
+ frozen. Your own arrays and hashes are copied, never frozen.
37
+
38
+ ### Added
39
+
40
+ - **`PhlexForms.finalize!`**: freezes the configuration and stores
41
+ Ractor-shareable copies of its settings. Idempotent; returns the
42
+ configuration. `PhlexForms::Theme::DAISY` / `PLAIN` are the built-in themes,
43
+ built once when the theme loads (`Theme.daisy` / `Theme.plain` return them),
44
+ and every `Theme` is frozen.
45
+ - **Ractor support for the non-rendering paths.** On Ruby 4.0, field
46
+ inference, option resolution, theme resolution, the icon renderer and the
47
+ `form_options` / `live` settings work inside a non-main Ractor. Rendering
48
+ there waits on Phlex
49
+ ([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
50
+ - **conformance-kit 0.4 gates in CI**: the thread-safety RuboCop cops, thread
51
+ and fiber contention specs, a Ractor canary and a strict
52
+ `conformance-audit` of `PhlexForms` and `Forms` with nothing allowlisted.
53
+
54
+ ### Changed
55
+
56
+ - **The configuration is frozen after boot** (see Upgrading to 0.5).
57
+ `Configuration` sets every default in `initialize`; no reader memoizes. The
58
+ default icon renderer and `Configuration.glyphs_renderer` are shareable.
59
+ - `rich_textarea` renders `<lexxy-editor>` through Phlex's `tag` instead of
60
+ `register_element`; the markup is the same.
61
+
62
+ ### Fixed
63
+
64
+ - **The themes build without phlex-reactive.** `Theme.daisy` named
65
+ `Forms::TagField` before checking for the soft dependency, so a host without
66
+ phlex-reactive got `NameError: uninitialized constant Forms::TagField` on its
67
+ first form.
68
+ - **`Theme.daisy` / `Theme.plain` are built once.** Two threads resolving a
69
+ theme for the first time could each build their own.
70
+ - **phlex-forms loads without Rails with phlex-reactive bundled.** phlex-reactive's
71
+ `Sortable` calls `Hash#to_json` while it loads without requiring json, so a
72
+ plain Ruby host got `NoMethodError` on its first form (and, with finalizing,
73
+ from `configure`). phlex-forms requires json before phlex-reactive.
74
+
75
+ ## [0.4.1] and earlier
76
+
10
77
  ### Upgrading to 0.4
11
78
 
12
79
  phlex-forms now depends on **daisyui `~> 2.0`** and merges every default +
data/README.md CHANGED
@@ -437,6 +437,33 @@ PhlexForms.configure do |c|
437
437
  end
438
438
  ```
439
439
 
440
+ ## Configuration is finalized at boot
441
+
442
+ `PhlexForms.configure` writes the configuration; finalizing freezes it and
443
+ stores Ractor-shareable copies of its settings, so threads and fibers read one
444
+ immutable value, and so can Ractors when every setting can be shared (see
445
+ below). It happens on its own:
446
+
447
+ - **Under Rails** the engine finalizes in `after_initialize`, once every
448
+ initializer has run, so initializers can still write (through `configure`
449
+ or directly).
450
+ - **Without Rails** (no `Rails::Engine` when phlex-forms loads), `configure`
451
+ finalizes when its block returns. A host that never calls `configure`, or
452
+ loads Rails without booting an app, calls `PhlexForms.finalize!` at boot.
453
+
454
+ After that, write through `configure` again (it edits a copy and swaps it in);
455
+ a direct `PhlexForms.config.theme = :plain` raises `FrozenError`. Your own
456
+ arrays and hashes are copied, never frozen.
457
+
458
+ Finalizing never raises over a value it cannot share: that value is kept as
459
+ given and works on the main Ractor only. An icon renderer lambda at the top
460
+ level of an initializer (its `self` is `main`, and it may close over a logger)
461
+ is one; define it in a class or module body (or with `Ractor.shareable_lambda`
462
+ on Ruby 4.0) to keep the whole configuration shareable. A renderer that is not
463
+ a Proc (an object with `#call`, a `Method`) is never copied or frozen, so one
464
+ that keeps a cache still works. `PhlexForms.reset_configuration!` restores the
465
+ open defaults (for test suites).
466
+
440
467
  ## RuboCop cops
441
468
 
442
469
  Two cops nudge call sites toward the phlex-forms API. Enable them in your
data/lib/forms/base.rb CHANGED
@@ -26,9 +26,14 @@ module Forms
26
26
  # (instance args beat class defaults; subclass defaults beat parents'):
27
27
  #
28
28
  # form_options :spaced, url: "/signup", validate: true
29
+ #
30
+ # A class macro: it runs while the form class body loads (at boot, on the
31
+ # main Ractor) and stores Ractor-shareable copies of its arguments
32
+ # (PhlexForms::Shareable.copy), so the readers work in any Ractor.
33
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
29
34
  def form_options(*modifiers, **defaults)
30
- @form_modifiers = modifiers
31
- @form_defaults = defaults
35
+ @form_modifiers = PhlexForms::Shareable.copy(modifiers)
36
+ @form_defaults = PhlexForms::Shareable.copy(defaults)
32
37
  end
33
38
 
34
39
  def form_modifiers
@@ -40,6 +45,7 @@ module Forms
40
45
  inherited = superclass.respond_to?(:form_defaults) ? superclass.form_defaults : {}
41
46
  inherited.merge(@form_defaults || {})
42
47
  end
48
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
43
49
 
44
50
  # Server-truth live validation via phlex-reactive (a soft dependency):
45
51
  #
data/lib/forms/live.rb CHANGED
@@ -42,10 +42,15 @@ module Forms
42
42
  end
43
43
 
44
44
  class_methods do
45
+ # live (setup_live), live_permit, live_deny and live_tags are class macros:
46
+ # they run while the form class body loads (at boot, on the main Ractor)
47
+ # and store Ractor-shareable values (PhlexForms::Shareable.copy), so the
48
+ # readers work in any Ractor.
49
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
45
50
  def setup_live(model_class:, scope:, debounce:)
46
51
  @live_model_class = model_class
47
- @live_scope = scope.to_s
48
- @live_debounce = debounce
52
+ @live_scope = PhlexForms::Shareable.copy(scope.to_s)
53
+ @live_debounce = PhlexForms::Shareable.copy(debounce)
49
54
  reactive_state :model_gid, :touched
50
55
  action :validate, params: { scope.to_sym => :form_attributes, _touch: :string }
51
56
  # phlex-reactive >= 0.11 defaults verify_authorized ON: an action that
@@ -64,8 +69,8 @@ module Forms
64
69
  # Narrow (live_permit) or trim (live_deny) the attributes the :validate
65
70
  # action may assign. Default: the model's column/attribute names plus
66
71
  # every validated attribute (and its _confirmation twin).
67
- def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
68
- def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
72
+ def live_permit(*attrs) = @live_permit = PhlexForms::Shareable.copy(attrs.map(&:to_s))
73
+ def live_deny(*attrs) = @live_deny = PhlexForms::Shareable.copy(attrs.map(&:to_s))
69
74
 
70
75
  # Lift a tag field onto the form's reactive root so live :validate sees its
71
76
  # comma-joined value. The widget renders ROOTLESS (no nested reactive root),
@@ -90,7 +95,7 @@ module Forms
90
95
  "non-live)."
91
96
  end
92
97
 
93
- @live_tags = { name: name.to_sym, suggestions: }
98
+ @live_tags = PhlexForms::Shareable.copy({ name: name.to_sym, suggestions: })
94
99
  end
95
100
 
96
101
  def live_tags_declaration = @live_tags || inherited_live(:live_tags_declaration)
@@ -101,6 +106,7 @@ module Forms
101
106
  permitted |= [tag[:name].to_s] if tag
102
107
  permitted - (@live_deny || [])
103
108
  end
109
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
104
110
 
105
111
  private
106
112
 
@@ -219,5 +225,11 @@ module Forms
219
225
  @model.public_send(setter, value) if @model.respond_to?(setter)
220
226
  end
221
227
  end
228
+
229
+ # ActiveSupport::Concern's own list of the concerns this one includes
230
+ # (Phlex::Reactive::Component): filled while this module body loads and
231
+ # only read after, when a form class includes Forms::Live. Frozen, so the
232
+ # module holds no mutable state.
233
+ @_dependencies.freeze
222
234
  end
223
235
  end
@@ -6,8 +6,6 @@ module Forms
6
6
  # Renders an editor bound to the field; when the value is an ActionText::RichText
7
7
  # its HTML body is used as the initial content.
8
8
  class RichTextarea < Phlex::HTML
9
- register_element :lexxy_editor
10
-
11
9
  def initialize(*modifiers, name:, id: nil, value: nil, **options)
12
10
  @modifiers = modifiers
13
11
  @name = name
@@ -20,9 +18,12 @@ module Forms
20
18
 
21
19
  # Lexxy configures the editor through CHILD elements — `<lexxy-prompt>` for
22
20
  # @mentions, `<lexxy-code-language-picker>` for code blocks — so the block
23
- # has to reach the element, not be dropped.
21
+ # has to reach the element, not be dropped. Rendered through `tag`, not a
22
+ # `register_element` method, so the class keeps no element registry of its
23
+ # own.
24
24
  def view_template(&)
25
- lexxy_editor(
25
+ tag(
26
+ :lexxy_editor,
26
27
  name: @name,
27
28
  id: @id,
28
29
  value: formatted_value,
@@ -22,7 +22,23 @@ module PhlexForms
22
22
  # glyphs is intentionally NOT the default: rails_icons reads SVGs from the host
23
23
  # app's asset paths, which a minimal host (or the gem's own test suite) has not
24
24
  # set up — so defaulting to glyphs would raise Icons::IconNotFound.
25
+ #
26
+ # Finalized at boot: `configure` finalizes when the Rails engine is absent, the
27
+ # engine's after_initialize does under Rails (or call `PhlexForms.finalize!`).
28
+ # Finalizing freezes the configuration and stores Ractor-shareable copies of
29
+ # its settings, so any Ractor can read it. A renderer that cannot be shared (a
30
+ # lambda written at the top level of an initializer has `main` as its self) is
31
+ # kept as given and still works on the main Ractor; build it in a class or
32
+ # module body, or with `Ractor.shareable_lambda` on Ruby 4.0, to keep the
33
+ # whole configuration shareable. A renderer that is not a Proc (an object with
34
+ # #call, a Method) is never copied or frozen: it is shared only if it already
35
+ # is. After finalizing, write through `configure`, which edits a copy and
36
+ # swaps it in.
25
37
  class Configuration
38
+ # The bundled inline SVGs. Built in the class body (self is the class and the
39
+ # lambda captures nothing), so it can be made shareable once, here.
40
+ DEFAULT_ICON_RENDERER = Shareable.copy(->(name, **opts) { InlineIcons.render(name, **opts) })
41
+
26
42
  # A ready-made renderer that delegates to glyphs' LucideIcon. Raises a clear
27
43
  # error if glyphs is not loaded.
28
44
  def self.glyphs_renderer
@@ -31,7 +47,7 @@ module PhlexForms
31
47
  "PhlexForms glyphs_renderer requires the `glyphs` gem to be loaded"
32
48
  end
33
49
 
34
- ->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) }
50
+ Shareable.copy(->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) })
35
51
  end
36
52
 
37
53
  attr_writer :icon_renderer
@@ -43,29 +59,48 @@ module PhlexForms
43
59
 
44
60
  # Global daisyui variants prepended to every `field`'s inner input, e.g.
45
61
  # `c.field_variants = [:primary]`. Form-level `field_variants:` and
46
- # call-site modifiers stack after (later wins).
47
- attr_writer :field_variants
48
-
49
- def field_variants
50
- @field_variants ||= []
62
+ # call-site modifiers stack after (later wins). nil means none, stored as an
63
+ # empty list so `c.field_variants << :sm` keeps working.
64
+ def field_variants=(variants)
65
+ @field_variants = variants || []
51
66
  end
52
67
 
53
68
  # The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
54
69
  # Defaults to daisy.
55
70
  attr_writer :theme
56
71
 
72
+ # Every default is set here, never memoized by a reader, so concurrent
73
+ # readers never race to build one. The theme stays nil until set or
74
+ # finalized (the reader falls back to Theme.daisy), so building a
75
+ # configuration loads no component.
76
+ def initialize
77
+ @theme = nil
78
+ @infer_from_model = true
79
+ @field_variants = []
80
+ @icon_renderer = DEFAULT_ICON_RENDERER
81
+ end
82
+
83
+ # `configure` on a finalized configuration edits a copy: give the copy its
84
+ # own variants list, so `c.field_variants << :sm` works on it.
85
+ def initialize_copy(source)
86
+ super
87
+ @field_variants = @field_variants.dup
88
+ end
89
+
90
+ def field_variants
91
+ @field_variants || []
92
+ end
93
+
57
94
  def theme
58
- @theme ||= Theme.daisy
95
+ @theme || Theme.daisy
59
96
  end
60
97
 
61
98
  def infer_from_model
62
- return @infer_from_model if defined?(@infer_from_model) && !@infer_from_model.nil?
63
-
64
- true
99
+ @infer_from_model.nil? || @infer_from_model
65
100
  end
66
101
 
67
102
  def icon_renderer
68
- @icon_renderer ||= ->(name, **opts) { InlineIcons.render(name, **opts) }
103
+ @icon_renderer || DEFAULT_ICON_RENDERER
69
104
  end
70
105
 
71
106
  # Returns a renderable for the named icon. Components pass the result to Phlex
@@ -73,5 +108,18 @@ module PhlexForms
73
108
  def render_icon(name, **)
74
109
  icon_renderer.call(name.to_s, **)
75
110
  end
111
+
112
+ # Stores Ractor-shareable copies of every setting and freezes the
113
+ # configuration. Resolves the default theme, which builds both built-in
114
+ # themes at boot rather than on the first render. Idempotent; returns self.
115
+ def finalize!
116
+ return self if frozen?
117
+
118
+ @theme = Shareable.copy(theme)
119
+ @infer_from_model = Shareable.copy(@infer_from_model)
120
+ @field_variants = Shareable.copy(@field_variants)
121
+ @icon_renderer = Shareable.callable(@icon_renderer)
122
+ freeze
123
+ end
76
124
  end
77
125
  end
@@ -32,6 +32,10 @@ module PhlexForms
32
32
  app.config.i18n.load_path.unshift(*locales)
33
33
  end
34
34
 
35
+ # PhlexForms.configure leaves the configuration open under Rails; freeze it
36
+ # (with Ractor-shareable settings) once every initializer has run.
37
+ config.after_initialize { PhlexForms.finalize! }
38
+
35
39
  # The raw-hash param type Forms::Live's :validate action schema declares.
36
40
  # Registered from an initializer because phlex-reactive freezes its
37
41
  # param-type registry after boot.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Ractor-shareable copies of boot-time settings (the configuration, the
5
+ # form_options / live class macros), so any Ractor can read them after boot.
6
+ module Shareable
7
+ module_function
8
+
9
+ # A deep-frozen, Ractor-shareable copy of value; value itself is left as the
10
+ # caller has it. A Proc cannot be copied, so it is shared in place, which
11
+ # works only when its self and everything it captures are already shareable
12
+ # (it never freezes them). A value that cannot be shared (a Proc whose self
13
+ # is not shareable, an IO, ...) comes back unchanged: it still works on the
14
+ # main Ractor. Engines without Ractor get the value back as is.
15
+ def copy(value)
16
+ return value unless defined?(::Ractor)
17
+ return value if ::Ractor.shareable?(value)
18
+ return ::Ractor.make_shareable(value) if value.is_a?(::Proc)
19
+
20
+ ::Ractor.make_shareable(value, copy: true)
21
+ rescue ::Ractor::Error, ::TypeError
22
+ value
23
+ end
24
+
25
+ # A callable (the icon renderer) shared only where that leaves it working: a
26
+ # Proc as in copy, any other object kept as given. A frozen copy of an
27
+ # object would break one that keeps state of its own (a cache, a buffer)
28
+ # on its first call, and a Method cannot be copied.
29
+ def callable(value)
30
+ value.is_a?(::Proc) ? copy(value) : value
31
+ end
32
+ end
33
+ end
@@ -19,8 +19,10 @@ module PhlexForms
19
19
  #
20
20
  # PhlexForms::Theme.resolve(:plain).with(input: MyInput)
21
21
  class Theme
22
+ # Frozen, so a theme of component classes is Ractor-shareable.
22
23
  def initialize(components)
23
24
  @components = components.freeze
25
+ freeze
24
26
  end
25
27
 
26
28
  def [](role)
@@ -45,48 +47,53 @@ module PhlexForms
45
47
  end
46
48
  end
47
49
 
48
- def daisy
49
- @daisy ||= new({
50
- input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
51
- textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
52
- checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
53
- checkbox_group: Forms::CheckboxGroup,
54
- file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
55
- control: Forms::FormControl, label: Forms::Label,
56
- field_error: Forms::FieldError, field_hint: Forms::FieldHint,
57
- submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
58
- # tag_field is phlex-reactive-gated (ClientBindings); only registered
59
- # when the soft dep is present, else :tag_field raises a clear KeyError.
60
- **reactive_roles(Forms::TagField, Forms::RootlessTagField)
61
- })
62
- end
50
+ def daisy = DAISY
63
51
 
64
- # choices_select degrades to the native plain select (no choices.js) and
65
- # rich_textarea to a plain textarea; toggle renders as a checkbox.
66
- def plain
67
- @plain ||= new({
68
- input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
69
- textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
70
- checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
71
- checkbox_group: Forms::Plain::CheckboxGroup,
72
- file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
73
- control: Forms::Plain::Control, label: Forms::Plain::Label,
74
- field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
75
- submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
76
- **reactive_roles(Forms::Plain::TagField, Forms::Plain::RootlessTagField)
77
- })
78
- end
52
+ def plain = PLAIN
79
53
 
80
54
  private
81
55
 
82
56
  # phlex-reactive-gated roles: mapped only when the soft dep is present. When
83
57
  # absent, the leaf classes aren't autoloaded, so these roles are simply not
84
58
  # registered (fetching one raises the theme's own KeyError with the list).
85
- def reactive_roles(tag_field_class, rootless_tag_field_class)
59
+ # Takes the namespace, not the classes: naming Forms::TagField without
60
+ # phlex-reactive would raise NameError before the guard ran.
61
+ def reactive_roles(namespace)
86
62
  return {} unless defined?(Phlex::Reactive)
87
63
 
88
- { tag_field: tag_field_class, rootless_tag_field: rootless_tag_field_class }
64
+ { tag_field: namespace::TagField, rootless_tag_field: namespace::RootlessTagField }
89
65
  end
90
66
  end
67
+
68
+ # Both built-ins are built once, when this file loads (never memoized by a
69
+ # reader, so no two threads race to build one; PhlexForms.finalize! loads
70
+ # it at boot), and the phlex-reactive probe is evaluated once, here.
71
+ DAISY = new({
72
+ input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
73
+ textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
74
+ checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
75
+ checkbox_group: Forms::CheckboxGroup,
76
+ file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
77
+ control: Forms::FormControl, label: Forms::Label,
78
+ field_error: Forms::FieldError, field_hint: Forms::FieldHint,
79
+ submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
80
+ # tag_field is phlex-reactive-gated (ClientBindings); only registered
81
+ # when the soft dep is present, else :tag_field raises a clear KeyError.
82
+ **reactive_roles(Forms)
83
+ })
84
+
85
+ # choices_select degrades to the native plain select (no choices.js) and
86
+ # rich_textarea to a plain textarea; toggle renders as a checkbox.
87
+ PLAIN = new({
88
+ input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
89
+ textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
90
+ checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
91
+ checkbox_group: Forms::Plain::CheckboxGroup,
92
+ file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
93
+ control: Forms::Plain::Control, label: Forms::Plain::Label,
94
+ field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
95
+ submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
96
+ **reactive_roles(Forms::Plain)
97
+ })
91
98
  end
92
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/phlex_forms.rb CHANGED
@@ -10,6 +10,11 @@ require "zeitwerk"
10
10
  require "daisy_ui"
11
11
 
12
12
  begin
13
+ # phlex-reactive's Sortable calls Hash#to_json while it loads without
14
+ # requiring json (a Rails host has it loaded already). Finalizing builds the
15
+ # themes, which load the tag-field leaves and with them Sortable, so without
16
+ # Rails `configure` would raise NoMethodError.
17
+ require "json"
13
18
  require "phlex/reactive"
14
19
  rescue LoadError
15
20
  # phlex-reactive is a soft dependency: without it the `live` server-truth
@@ -32,17 +37,38 @@ module PhlexForms
32
37
  # used but its backing dependency is not available.
33
38
  class FeatureUnavailable < Error; end
34
39
 
40
+ # @configuration is built when this file loads (see the bottom) and replaced
41
+ # only by configure and reset_configuration!, on the main Ractor at boot or
42
+ # between tests (a non-main Ractor cannot write a module ivar). Once finalized
43
+ # it is frozen and Ractor-shareable, so any Ractor may read it.
44
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
35
45
  class << self
36
- def configuration
37
- @configuration ||= Configuration.new
38
- end
46
+ attr_reader :configuration
39
47
  alias config configuration
40
48
 
49
+ # Yields the configuration, then finalizes it unless the Rails engine is
50
+ # loaded (its after_initialize finalizes once every initializer has run). A
51
+ # finalized configuration is never edited in place: the block gets a copy,
52
+ # which is finalized and swapped in whole. Returns the block's value.
41
53
  def configure
42
- yield(configuration)
54
+ if @configuration.frozen?
55
+ draft = @configuration.dup
56
+ result = yield(draft)
57
+ @configuration = draft.finalize!
58
+ else
59
+ result = yield(@configuration)
60
+ finalize! unless defined?(PhlexForms::Engine)
61
+ end
62
+ result
63
+ end
64
+
65
+ # Freezes the configuration with Ractor-shareable settings (see
66
+ # Configuration#finalize!). Idempotent; returns the configuration.
67
+ def finalize!
68
+ @configuration.finalize!
43
69
  end
44
70
 
45
- # Reset configuration to defaults. Intended for test isolation.
71
+ # Reset configuration to defaults, open again. Intended for test isolation.
46
72
  def reset_configuration!
47
73
  @configuration = Configuration.new
48
74
  end
@@ -97,6 +123,10 @@ end
97
123
 
98
124
  loader.setup
99
125
 
126
+ # The default configuration, built at load (never lazily, so no two readers
127
+ # race to create it).
128
+ PhlexForms.reset_configuration!
129
+
100
130
  # Optional Rails integration (Stimulus controllers via importmap/assets, and
101
131
  # the gem's default locale files). Loaded only under Rails; the gem stays pure
102
132
  # Phlex otherwise.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -189,6 +189,7 @@ files:
189
189
  - lib/phlex_forms/inference.rb
190
190
  - lib/phlex_forms/inline_icons.rb
191
191
  - lib/phlex_forms/rubocop.rb
192
+ - lib/phlex_forms/shareable.rb
192
193
  - lib/phlex_forms/theme.rb
193
194
  - lib/phlex_forms/version.rb
194
195
  - lib/rubocop/phlex_forms/cop/legacy_form_method.rb