daisyui 2.0.2 → 2.1.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: 784f97cabf197c081daba3827d5d7cc67a4bfe68b1be95fd038cecaae9da127c
4
- data.tar.gz: e1cc4be0135efdf03ed592a8c0d73e2b12d81c4cee47dacd7008ac34aa8bdbbe
3
+ metadata.gz: 41fa98f82c0377c4615fa5e4a1091ae8383688a951acb726122866cefa273665
4
+ data.tar.gz: 651713886837f46040942fc397089d1194dc35787ab1ba18308db97f12d94a06
5
5
  SHA512:
6
- metadata.gz: d6331604dbf5bdd7b419dfcbb5494cf7569a1709b3d9b17dd7fe08d56d1e3fed14b16c4d1925534a4efdce23e4e85347901b5687580f466e02aa9081f5ca646f
7
- data.tar.gz: 002c0ad206f54764209eb6ae752c852a91369adb97c84910fe39daf19ca993842b773f6f60ad27da12cc88d25e6d7df8cc6415018408a541c99533d214432193
6
+ metadata.gz: be24df2be52d01adbda5f9e257650d788352a0dfd286bee5c734dfce66aee0377dd0ad601269363ffd734d908cd0c2cde612062017dc59fde61086f4f53e716c
7
+ data.tar.gz: 2320af028c2539b849de1feb7c1d2690cc6ca0e75da24bd7d838415171c4d922f59b26f5833e1cc1a1e035c330ce532eaae75e21107bd89119f24653f7ab3eb4
data/CHANGELOG.md CHANGED
@@ -1,4 +1,41 @@
1
- ## [Unreleased]
1
+ ## [2.1.0]
2
+
3
+ ### DaisyUI finalizes itself at boot: class merging in any Ractor (#58)
4
+
5
+ At boot DaisyUI now freezes its configuration and every component's
6
+ registries, so a non-main Ractor can read them. `DaisyUI::ClassMerge.merge`
7
+ then works in any Ractor; each Ractor builds its own merger and LRU.
8
+
9
+ - Automatic: `DaisyUI.configure` finalizes at its end, and the Rails engine
10
+ finalizes again in `after_initialize`, with no app code. A Rails-less app
11
+ that never calls `configure` calls `DaisyUI.finalize!` at the end of boot.
12
+ - **The configuration is frozen after `DaisyUI.configure` (and, in a Rails
13
+ app, after boot).** Writing to `DaisyUI.configuration` directly now raises
14
+ `FrozenError`, e.g. `DaisyUI.configuration.unknown_modifiers = :ignore` in a
15
+ spec: use `DaisyUI.configure { |config| config.unknown_modifiers = :ignore }`.
16
+ `DaisyUI.configure` can be called again at any time: it yields a writable
17
+ copy and publishes it once that is finalized, so no reader ever meets a
18
+ half-changed configuration, and a block that raises changes nothing. A
19
+ Ractor that already built its merger rebuilds it from the new
20
+ configuration.
21
+ - Finalizing never raises. A configured value that cannot be made shareable
22
+ is kept as it is, unfrozen, and the configuration is then readable on the
23
+ main Ractor only: a callable `unknown_modifiers` or a raw `class_merge`
24
+ validator that closes over app state (such as
25
+ `->(error) { Appsignal.report_error(error) }` in an initializer), or an
26
+ object that holds a logger.
27
+ - Component registries (`register_modifiers`, `register_flags`,
28
+ `self.component_class =`) are frozen as they are written, so a component
29
+ that loads after boot (lazy loading in development) is readable from any
30
+ Ractor too. Writers now replace them instead of mutating them, and
31
+ mutating one in place raises `FrozenError`.
32
+ - Thread safety: the Tailwind class groups behind `ClassMerge` were built
33
+ with an unguarded `||=` (8 threads built them 8 times); they are now built
34
+ once per Ractor.
35
+ - Rendering components inside a Ractor waits on Phlex
36
+ ([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
37
+
38
+ ## [2.0.2] and earlier
2
39
 
3
40
  ### Unknown modifiers are reported (#54)
4
41
 
data/README.md CHANGED
@@ -209,6 +209,45 @@ end
209
209
  Keyword arguments are not validated: `Toggle(primray: true)` renders a
210
210
  `primray` attribute, because unknown keywords are HTML attributes.
211
211
 
212
+ ## Threads, fibers and Ractors
213
+
214
+ Components and `DaisyUI::ClassMerge` are safe to share between threads (Puma)
215
+ and fibers (Falcon). For Ractors, DaisyUI finalizes itself at boot: it freezes
216
+ the configuration and every component's registries, so
217
+ `DaisyUI::ClassMerge.merge` runs in any Ractor (as long as the configuration
218
+ is shareable, see below), each Ractor with a merger of its own.
219
+ `DaisyUI.configure` finalizes at its end and the Rails engine
220
+ finalizes again once the app has booted, so there is nothing to call. A
221
+ Rails-less app that never calls `configure` calls `DaisyUI.finalize!` at the
222
+ end of boot.
223
+
224
+ After that the configuration is frozen: writing to `DaisyUI.configuration`
225
+ directly raises `FrozenError`. `DaisyUI.configure` keeps working at any time:
226
+ it yields a writable copy and publishes it once that is finalized, and a
227
+ Ractor that already built its merger rebuilds it from the new configuration.
228
+ A test that changes a setting goes through it too:
229
+
230
+ ```ruby
231
+ around do |example|
232
+ DaisyUI.configure { |config| config.unknown_modifiers = :ignore }
233
+ example.run
234
+ ensure
235
+ DaisyUI.configure { |config| config.unknown_modifiers = :raise }
236
+ end
237
+ ```
238
+
239
+ Finalizing never raises. A configured value that cannot be made shareable is
240
+ kept as it is, and the configuration is then readable on the main Ractor only:
241
+ a callable `unknown_modifiers` or a raw `class_merge` validator that closes
242
+ over app state (`->(error) { Appsignal.report_error(error) }` in an
243
+ initializer), or an object that holds a logger. A lambda is shareable when it
244
+ captures no local variables and is defined inside a module, or made with
245
+ `Ractor.shareable_lambda` on Ruby 4.0. A component that loads after boot (lazy
246
+ loading in development) freezes its registries as it writes them, so any
247
+ Ractor can read it; a Ractor that already built its merger picks up its groups
248
+ after the next `configure`. Rendering components inside a Ractor waits on Phlex
249
+ ([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
250
+
212
251
  # Compatibility Notes
213
252
 
214
253
  ## @tailwindcss/forms plugin
data/lib/daisy_ui/base.rb CHANGED
@@ -118,14 +118,37 @@ module DaisyUI
118
118
  error: "bg-error text-error-content"
119
119
  }.freeze
120
120
 
121
+ # Component registries: written by the class body (register_modifiers,
122
+ # register_flags, `self.component_class =`) and by `inherited`, on the main
123
+ # Ractor while the class loads; rendering only reads them. Every writer
124
+ # assigns a new value instead of mutating the old one and freezes it as it
125
+ # writes (Shareable.share), so a non-main Ractor can read the registries of
126
+ # a component that loaded after boot too, and a later register_* still
127
+ # works.
121
128
  class << self
122
- attr_writer :component_class, :modifier_groups, :flags
123
- attr_accessor :modifiers
129
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- see above
130
+ attr_reader :modifiers
131
+
132
+ def modifiers=(modifiers)
133
+ @modifiers = Shareable.share(modifiers)
134
+ end
135
+
136
+ def modifier_groups=(groups)
137
+ @modifier_groups = Shareable.share(groups)
138
+ end
139
+
140
+ def flags=(flags)
141
+ @flags = Shareable.share(flags)
142
+ end
143
+
144
+ def component_class=(component_class)
145
+ @component_class = Shareable.share(component_class)
146
+ end
124
147
 
125
148
  # Positional symbols a component handles itself (e.g. Dropdown's
126
149
  # :popover): accepted as modifiers, but they emit no class.
127
150
  def flags
128
- @flags ||= []
151
+ @flags || []
129
152
  end
130
153
 
131
154
  def register_flags(*names)
@@ -135,20 +158,24 @@ module DaisyUI
135
158
  # { group => [modifier, ...] }: modifiers in one group are alternatives
136
159
  # (sizes, colors, ...), so ClassMerge keeps only the last one.
137
160
  def modifier_groups
138
- @modifier_groups ||= {}
161
+ @modifier_groups || {}
139
162
  end
140
163
 
164
+ # Derived from the class name on first use, the same value every time.
165
+ # Remembered on the main Ractor only (DaisyUI.finalize! derives it at
166
+ # boot); a non-main Ractor cannot set a class ivar, so it derives it.
141
167
  def component_class
142
168
  return @component_class if instance_variable_defined?(:@component_class)
143
169
 
144
- @component_class = begin
170
+ derived = begin
145
171
  # Convert "DaisyUI::Button" -> "button"
146
172
  class_name = name.split("::").last # demodulize
147
173
  class_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
148
174
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
149
175
  .tr("_", "-")
150
- .downcase
176
+ .downcase.freeze
151
177
  end
178
+ Store.main_ractor? ? (@component_class = derived) : derived
152
179
  end
153
180
 
154
181
  def inherited(subclass)
@@ -161,6 +188,7 @@ module DaisyUI
161
188
 
162
189
  subclass.component_class = @component_class
163
190
  end
191
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
164
192
 
165
193
  # Registers modifiers. A Hash value declares a group of alternatives:
166
194
  #
@@ -178,15 +206,23 @@ module DaisyUI
178
206
  end
179
207
  end
180
208
 
181
- modifier_groups.each_value { |members| members.delete_if { |member| flat.key?(member) } }
209
+ groups = modifier_groups.transform_values { |members| members.reject { |member| flat.key?(member) } }
182
210
  mods.each do |group, value|
183
- (modifier_groups[group] ||= []).concat(value.keys) if value.is_a?(Hash)
211
+ groups[group] = (groups[group] || []) + value.keys if value.is_a?(Hash)
184
212
  end
185
- modifier_groups.delete_if { |_group, members| members.empty? }
186
213
 
214
+ self.modifier_groups = groups.reject { |_group, members| members.empty? }
187
215
  self.modifiers = (modifiers || {}).merge(flat)
188
216
  ClassMerge.reset!
189
217
  end
218
+
219
+ # @api private Called by DaisyUI.finalize! for every component: derives
220
+ # component_class on the main Ractor and makes sure every registry is
221
+ # frozen for non-main Ractors.
222
+ def finalize!
223
+ [component_class, modifiers, modifier_groups, flags].each { |registry| Shareable.share(registry) }
224
+ self
225
+ end
190
226
  end
191
227
 
192
228
  def initialize(*modifiers, as: :div, id: nil, **options)
@@ -24,6 +24,20 @@ module DaisyUI
24
24
  @conflicts = {}
25
25
  end
26
26
 
27
+ def initialize_copy(source)
28
+ super
29
+ @theme = @theme.transform_values(&:dup)
30
+ @class_groups = @class_groups.transform_values(&:dup)
31
+ @conflicts = @conflicts.transform_values(&:dup)
32
+ end
33
+
34
+ # @api private Called by DaisyUI.finalize!. A raw class group's
35
+ # validator that closes over app state is kept as it is.
36
+ def finalize!
37
+ [@theme, @class_groups, @conflicts].each { |settings| Shareable.share(settings) }
38
+ freeze
39
+ end
40
+
27
41
  # Registers utilities that behave like an existing one:
28
42
  # `utility("text-display", like: "text-lg")` makes `text-display` a font
29
43
  # size, so it replaces `text-sm` and sits next to `text-error`.
@@ -57,11 +71,12 @@ module DaisyUI
57
71
  append(@conflicts, conflicts)
58
72
  end
59
73
 
60
- # @api private The config passed to Merger.new, including the daisyUI groups.
61
- def merger_config
74
+ # @api private The config passed to Merger.new, including the daisyUI
75
+ # groups of +configuration+ (the DaisyUI configuration holding this one).
76
+ def merger_config(configuration = DaisyUI.configuration)
62
77
  {
63
78
  theme: @theme,
64
- class_groups: DaisyGroups.build.merge(@class_groups) { |_id, existing, added| existing + added },
79
+ class_groups: DaisyGroups.build(configuration).merge(@class_groups) { |_id, existing, added| existing + added },
65
80
  conflicting_class_groups: @conflicts
66
81
  }
67
82
  end
@@ -18,8 +18,9 @@ module DaisyUI
18
18
  # @api private
19
19
  module DaisyGroups
20
20
  class << self
21
- # Class groups for the merger: `{ "daisy:group:btn-size" => ["btn-xs", ...], ... }`.
22
- def build(prefix: DaisyUI.configuration.prefix)
21
+ # Class groups for the merger: `{ "daisy:group:btn-size" => ["btn-xs", ...], ... }`,
22
+ # from one configuration's prefix and modifiers.
23
+ def build(configuration = DaisyUI.configuration, prefix: configuration.prefix)
23
24
  components = self.components
24
25
  groups = {}
25
26
 
@@ -37,7 +38,7 @@ module DaisyUI
37
38
  end
38
39
 
39
40
  # Configured modifiers join the component's own group of that name.
40
- DaisyUI.configuration.modifiers.groups.each do |(component, group), classes|
41
+ configuration.modifiers.groups.each do |(component, group), classes|
41
42
  owner = component ? component.component_class || component.name : "global"
42
43
  add(groups, "daisy:group:#{owner}-#{group}", classes.flat_map(&:split), prefix)
43
44
  end
@@ -51,9 +52,11 @@ module DaisyUI
51
52
 
52
53
  # Every named DaisyUI::Base subclass: the gem's components (eager
53
54
  # loaded) and any app component loaded so far. An app component that
54
- # loads later resets the merger through `register_modifiers`.
55
+ # loads later resets the merger through `register_modifiers`. Only the
56
+ # main Ractor can eager-load; DaisyUI.finalize! did it there before
57
+ # any other Ractor builds a merger.
55
58
  def components
56
- Zeitwerk::Loader.eager_load_namespace(DaisyUI)
59
+ Zeitwerk::Loader.eager_load_namespace(DaisyUI) if Store.main_ractor?
57
60
  descendants(DaisyUI::Base).select(&:name)
58
61
  end
59
62
 
@@ -8,21 +8,6 @@ module DaisyUI
8
8
  module TailwindConfig
9
9
  include Validators
10
10
 
11
- class << self
12
- private
13
-
14
- # Recursively freezes nested Hash/Array structures so shared config
15
- # constants can't be mutated in place. Keys are strings/symbols and are
16
- # already immutable, so only values need walking.
17
- def deep_freeze(object)
18
- case object
19
- when Hash then object.each_value { |value| deep_freeze(value) }
20
- when Array then object.each { |value| deep_freeze(value) }
21
- end
22
- object.freeze
23
- end
24
- end
25
-
26
11
  THEME_KEYS = %w[
27
12
  color
28
13
  font
@@ -46,8 +31,8 @@ module DaisyUI
46
31
  ].freeze
47
32
  THEME_KEYS.each do |key|
48
33
  const_set("THEME_#{key.upcase.tr('-', '_')}", lambda { |config|
49
- config[:theme].fetch(key, nil)
50
- }
34
+ config[:theme].fetch(key, nil)
35
+ }
51
36
  )
52
37
  end
53
38
 
@@ -2465,7 +2450,12 @@ module DaisyUI
2465
2450
  "selection"
2466
2451
  ]
2467
2452
  }.freeze
2468
- deep_freeze(DEFAULTS)
2453
+
2454
+ # Every constant above, DEFAULTS deeply, is made Ractor-shareable so a
2455
+ # non-main Ractor can build its own merger from them: the scale and theme
2456
+ # lambdas are capture-free (THEME_* only reads its frozen key) and their
2457
+ # self is this module.
2458
+ constants(false).each { |name| Shareable.make(const_get(name)) }
2469
2459
 
2470
2460
  # Extends DEFAULTS with incoming_config without mutating either.
2471
2461
  # Theme scales are appended. Unlike upstream, `class_groups` and
@@ -197,6 +197,10 @@ module DaisyUI
197
197
  IS_LABEL_SHADOW = lambda { |label|
198
198
  label == "shadow"
199
199
  }
200
+
201
+ # Ractor-shareable, so a non-main Ractor can read them: every validator
202
+ # is a capture-free lambda whose self is this module.
203
+ constants(false).each { |name| Shareable.make(const_get(name)) }
200
204
  end
201
205
  end
202
206
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
3
  module DaisyUI
6
4
  # Merges CSS class lists so that later classes win over earlier conflicting
7
5
  # ones, for Tailwind utilities and daisyUI modifiers alike:
@@ -16,10 +14,10 @@ module DaisyUI
16
14
  module ClassMerge
17
15
  TAILWIND_MERGE_VERSION = "1.5.6"
18
16
 
19
- # Reentrant, so a reset! triggered while the merger is being built (e.g. a
20
- # component file configuring DaisyUI as it loads) cannot deadlock.
21
- @monitor = Monitor.new
22
-
17
+ # The merger (with its LRU) and the Tailwind class groups live in
18
+ # DaisyUI::Store: one per Ractor, built on first use under the store's
19
+ # reentrant lock, so a reset! triggered while the merger is being built
20
+ # (e.g. a component file configuring DaisyUI as it loads) cannot deadlock.
23
21
  class << self
24
22
  # Accepts strings, nested arrays, nil and false (dropped, as Phlex does
25
23
  # for `class:`). Returns a frozen String, "" when empty.
@@ -27,20 +25,33 @@ module DaisyUI
27
25
  merger.merge(parts.flatten.select(&:itself).join(" "))
28
26
  end
29
27
 
28
+ # Each Ractor's merger remembers the configuration it was built from
29
+ # and is rebuilt once DaisyUI.configure publishes another, also in a
30
+ # Ractor where reset! never ran.
30
31
  def merger
31
- @merger || @monitor.synchronize do
32
- @merger ||= Merger.new(config: DaisyUI.configuration.class_merge.merger_config)
33
- end
32
+ configuration = DaisyUI.configuration
33
+ built_for, merger = Store.fetch(:daisy_ui_class_merger) { build_merger(configuration) }
34
+ return merger if built_for.equal?(configuration)
35
+
36
+ Store.delete(:daisy_ui_class_merger)
37
+ Store.fetch(:daisy_ui_class_merger) { build_merger(configuration) }.last
34
38
  end
35
39
 
36
40
  # @api private The stock Tailwind class groups, for classifying tokens.
37
41
  def tailwind_utils
38
- @tailwind_utils ||= ClassGroupUtils.new(TailwindConfig::DEFAULTS)
42
+ Store.fetch(:daisy_ui_tailwind_utils) { ClassGroupUtils.new(TailwindConfig::DEFAULTS) }
39
43
  end
40
44
 
41
- # Drops the memoized merger so the next merge picks up configuration changes.
45
+ # Drops the current Ractor's merger so its next merge picks up
46
+ # configuration changes.
42
47
  def reset!
43
- @monitor.synchronize { @merger = nil }
48
+ Store.delete(:daisy_ui_class_merger)
49
+ end
50
+
51
+ private
52
+
53
+ def build_merger(configuration)
54
+ [configuration, Merger.new(config: configuration.class_merge.merger_config(configuration))]
44
55
  end
45
56
  end
46
57
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Configurable
5
+ # @api private The unknown modifiers already reported, so :warn and a
6
+ # callable fire once per key. Remembers up to `limit` keys, then starts
7
+ # over, so modifiers built from user input cannot grow it without bound.
8
+ # One per Ractor (DaisyUI::Store), so its Mutex is never shared.
9
+ class ReportLog
10
+ def initialize(limit)
11
+ @limit = limit
12
+ @keys = Set.new
13
+ @lock = Mutex.new
14
+ end
15
+
16
+ # True the first time a key is seen.
17
+ def first?(key)
18
+ @lock.synchronize do
19
+ return false if @keys.include?(key)
20
+
21
+ @keys.clear if @keys.size >= @limit
22
+ @keys.add(key)
23
+ true
24
+ end
25
+ end
26
+
27
+ def clear
28
+ @lock.synchronize { @keys.clear }
29
+ end
30
+
31
+ def size
32
+ @lock.synchronize { @keys.size }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,15 +2,45 @@
2
2
 
3
3
  module DaisyUI
4
4
  module Configurable
5
+ # Created when DaisyUI extends this module, at load, so `configuration` is
6
+ # a plain read with no check-then-set race.
7
+ def self.extended(base)
8
+ base.instance_variable_set(:@configuration, Configuration.new)
9
+ end
10
+
11
+ # Yields a writable copy of the configuration, then finalizes the copy
12
+ # (see finalize!) and publishes it. Calling configure again keeps
13
+ # working, no reader (another thread, a non-main Ractor) meets a
14
+ # half-changed or writable configuration, and a block that raises
15
+ # publishes nothing. Calls are serialized, so each starts from what the
16
+ # previous one published.
5
17
  def configure
6
- self.configuration ||= Configuration.new
7
- yield(configuration) if block_given?
8
- ClassMerge.reset!
9
- configuration
18
+ configure_lock.synchronize do
19
+ draft = configuration.dup
20
+ yield(draft) if block_given?
21
+ @configuration = draft.finalize!
22
+ ClassMerge.reset!
23
+ finalize!
24
+ configuration
25
+ end
10
26
  end
11
27
 
12
28
  def configuration
13
- @configuration ||= Configuration.new
29
+ @configuration
30
+ end
31
+
32
+ # Freezes what a non-main Ractor reads (this configuration and every
33
+ # component's registries) so DaisyUI::ClassMerge.merge runs in any Ractor.
34
+ # configure calls it, and the Rails engine calls it once the app has
35
+ # booted; an app with neither calls it at the end of boot. Afterwards
36
+ # change settings only through configure: writing to the frozen
37
+ # configuration raises. A configured value that cannot be made shareable
38
+ # is kept as it is (see Shareable.share), never raised on.
39
+ def finalize!
40
+ ClassMerge::DaisyGroups.components.each(&:finalize!)
41
+ configuration.finalize!
42
+ ClassMerge.tailwind_utils
43
+ self
14
44
  end
15
45
 
16
46
  class Modifiers
@@ -19,6 +49,19 @@ module DaisyUI
19
49
  @groups = {}
20
50
  end
21
51
 
52
+ def initialize_copy(source)
53
+ super
54
+ @modifiers = @modifiers.transform_values(&:dup)
55
+ @groups = @groups.dup
56
+ end
57
+
58
+ # @api private Called by DaisyUI.finalize!.
59
+ def finalize!
60
+ Shareable.share(@modifiers)
61
+ Shareable.share(@groups)
62
+ freeze
63
+ end
64
+
22
65
  # `group:` makes the modifier an alternative to the component's other
23
66
  # modifiers in that group, e.g. `add(:huge, classes: "btn-xl",
24
67
  # component: DaisyUI::Button, group: :size)`.
@@ -59,8 +102,24 @@ module DaisyUI
59
102
 
60
103
  def initialize
61
104
  @prefix = nil
62
- @reported_unknown_modifiers = Set.new
63
- @reported_unknown_modifiers_lock = Mutex.new
105
+ end
106
+
107
+ def initialize_copy(source)
108
+ super
109
+ @class_merge = @class_merge&.dup
110
+ @modifiers = @modifiers&.dup
111
+ end
112
+
113
+ # @api private Called by DaisyUI.configure and DaisyUI.finalize!. A
114
+ # callable unknown_modifiers that cannot be made shareable (a lambda
115
+ # that closes over app state, an object holding a logger) is kept: the
116
+ # configuration is then frozen but readable on the main Ractor only.
117
+ def finalize!
118
+ class_merge.finalize!
119
+ modifiers.finalize!
120
+ Shareable.share(@prefix)
121
+ Shareable.share(@unknown_modifiers)
122
+ freeze
64
123
  end
65
124
 
66
125
  # What a component does with a positional modifier it does not know:
@@ -94,7 +153,7 @@ module DaisyUI
94
153
 
95
154
  # @api private
96
155
  def reset_reported_unknown_modifiers!
97
- @reported_unknown_modifiers_lock.synchronize { @reported_unknown_modifiers.clear }
156
+ reported_unknown_modifiers.clear
98
157
  end
99
158
 
100
159
  def prefix=(prefix)
@@ -112,16 +171,14 @@ module DaisyUI
112
171
 
113
172
  private
114
173
 
115
- # Remembers up to REPORTED_UNKNOWN_MODIFIERS_LIMIT keys, then starts over,
116
- # so modifiers built from user input cannot grow it without bound.
117
174
  def first_report?(key)
118
- @reported_unknown_modifiers_lock.synchronize do
119
- return false if @reported_unknown_modifiers.include?(key)
175
+ reported_unknown_modifiers.first?(key)
176
+ end
120
177
 
121
- @reported_unknown_modifiers.clear if @reported_unknown_modifiers.size >= REPORTED_UNKNOWN_MODIFIERS_LIMIT
122
- @reported_unknown_modifiers.add(key)
123
- true
124
- end
178
+ # What was already reported lives per Ractor, outside the configuration,
179
+ # which stays shareable.
180
+ def reported_unknown_modifiers
181
+ Store.fetch(:daisy_ui_reported_unknown_modifiers) { ReportLog.new(REPORTED_UNKNOWN_MODIFIERS_LIMIT) }
125
182
  end
126
183
 
127
184
  def default_unknown_modifiers
@@ -130,5 +187,13 @@ module DaisyUI
130
187
  %w[development test].include?(::Rails.env.to_s) ? :raise : :warn
131
188
  end
132
189
  end
190
+
191
+ private
192
+
193
+ # Reentrant, and one per Ractor (DaisyUI::Store) so it is never shared;
194
+ # configure writes DaisyUI's ivar, so only the main Ractor gets that far.
195
+ def configure_lock
196
+ Store.fetch(:daisy_ui_configure_lock) { Monitor.new }
197
+ end
133
198
  end
134
199
  end
@@ -34,6 +34,13 @@ module DaisyUI
34
34
  precompile.concat(MINIFIED_ASSETS) if precompile.is_a?(Array)
35
35
  end
36
36
 
37
+ # Once the app has booted, freeze the configuration and component
38
+ # registries so a non-main Ractor can merge classes. DaisyUI.configure in
39
+ # an initializer already finalized; this also covers an app that never
40
+ # calls it and the components loaded during boot (eager loading).
41
+ # DaisyUI.configure keeps working afterwards, on a copy.
42
+ config.after_initialize { DaisyUI.finalize! }
43
+
37
44
  initializer "daisy_ui.importmap", before: "importmap" do |app|
38
45
  next unless app.config.respond_to?(:importmap)
39
46
 
@@ -39,6 +39,7 @@ module DaisyUI
39
39
  }
40
40
  }
41
41
  ].freeze
42
+ Shareable.make(TOOLS) # the Hashes inside too
42
43
 
43
44
  EXCLUDED_CONSTANTS = %i[Base Configurable Configuration Modifiers VERSION UPDATED_AT McpServer].freeze
44
45
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # @api private Deep-freezes a value so a non-main Ractor can read it from a
5
+ # constant or a class ivar: `Ractor.make_shareable`, or, on engines without
6
+ # Ractor (JRuby, TruffleRuby), a walk freezing Hash values and Array elements.
7
+ module Shareable
8
+ class << self
9
+ # For the gem's own literals, which must all be shareable: raises if one
10
+ # is not.
11
+ def make(value)
12
+ ractor? ? ::Ractor.make_shareable(value) : deep_freeze(value)
13
+ end
14
+
15
+ # For what an app configures. Freezes Hashes, Arrays and Strings all the
16
+ # way down, makes a capture-free Proc shareable, and keeps anything else
17
+ # (a Proc that closes over app state, a logger) as it is, so whatever
18
+ # holds it is readable on the main Ractor only. Never raises, and never
19
+ # hands an object it does not know to Ractor.make_shareable, which
20
+ # freezes as it goes and would leave a logger frozen before failing.
21
+ def share(value)
22
+ case value
23
+ when Hash
24
+ value.each do |key, member|
25
+ share(key)
26
+ share(member)
27
+ end.freeze
28
+ when Array then value.each { |member| share(member) }.freeze
29
+ when String then value.freeze
30
+ when Proc then share_proc(value)
31
+ else value
32
+ end
33
+ end
34
+
35
+ def ractor?
36
+ defined?(::Ractor) ? ::Ractor.respond_to?(:make_shareable) : false
37
+ end
38
+
39
+ private
40
+
41
+ def share_proc(callable)
42
+ ractor? ? ::Ractor.make_shareable(callable) : callable
43
+ rescue ::Ractor::Error, ArgumentError
44
+ callable
45
+ end
46
+
47
+ def deep_freeze(value)
48
+ case value
49
+ when Hash then value.each_value { |member| deep_freeze(member) }
50
+ when Array then value.each { |member| deep_freeze(member) }
51
+ end
52
+ value.freeze
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module DaisyUI
6
+ # @api private Per-Ractor lazy storage for caches that must not live in a
7
+ # class ivar or a constant (the class-merge merger, the Tailwind class
8
+ # groups, the unknown-modifier report log):
9
+ #
10
+ # DaisyUI::Store.fetch(:daisy_ui_merger) { Merger.new(...) }
11
+ # DaisyUI::Store.delete(:daisy_ui_merger)
12
+ #
13
+ # Adapted from conformance-kit's ConformanceKit::Store (MIT, same author),
14
+ # which daisyui cannot depend on at runtime, plus `delete`. Every Ractor has
15
+ # its own Monitor-guarded table: reentrant, so an initializer may fetch or
16
+ # delete other keys. On Ruby 3.4+ the table comes from
17
+ # `Ractor.store_if_absent`. Below 3.4 the main Ractor's table is created at
18
+ # load (never lazily, so there is no check-then-set race), and a non-main
19
+ # Ractor keeps its own in Ractor-local storage.
20
+ module Store
21
+ # One Ractor's values. Reads skip the lock once a key is stored.
22
+ class Table
23
+ def initialize
24
+ @monitor = Monitor.new
25
+ @values = {}
26
+ end
27
+
28
+ def fetch(key)
29
+ @values.fetch(key) do
30
+ @monitor.synchronize { @values.fetch(key) { @values[key] = yield } }
31
+ end
32
+ end
33
+
34
+ def delete(key)
35
+ @monitor.synchronize { @values.delete(key) }
36
+ end
37
+ end
38
+
39
+ class << self
40
+ def fetch(key, &init)
41
+ raise ArgumentError, "DaisyUI::Store.fetch(#{key.inspect}) needs an initializer block" unless init
42
+
43
+ table.fetch(key, &init)
44
+ end
45
+
46
+ def delete(key)
47
+ table.delete(key)
48
+ end
49
+
50
+ def main_ractor?
51
+ !defined?(::Ractor) || ::Ractor.current == ::Ractor.main
52
+ end
53
+
54
+ def native?
55
+ defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
56
+ end
57
+
58
+ private
59
+
60
+ def table
61
+ return ::Ractor.store_if_absent(:daisy_ui_store) { Table.new } if native?
62
+ return ::Ractor.current[:daisy_ui_store] ||= Table.new unless main_ractor?
63
+
64
+ # Set once below, at load, below Ruby 3.4; only the main Ractor reads it.
65
+ @main_table # rubocop:disable ThreadSafety/ClassInstanceVariable
66
+ end
67
+ end
68
+
69
+ @main_table = Table.new unless native?
70
+ end
71
+ end
@@ -3,5 +3,5 @@
3
3
  module DaisyUI
4
4
  # This timestamp is automatically updated when releasing a new version
5
5
  # Format: YYYY-MM-DD HH:MM:SS UTC
6
- UPDATED_AT = "2026-10-11 16:15:37 UTC"
6
+ UPDATED_AT = "2026-10-15 20:45:37 UTC"
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DaisyUI
4
- VERSION = "2.0.2"
4
+ VERSION = "2.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daisyui
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -98,6 +98,7 @@ files:
98
98
  - lib/daisy_ui/collapse.rb
99
99
  - lib/daisy_ui/collapsible_sub_menu.rb
100
100
  - lib/daisy_ui/configurable.rb
101
+ - lib/daisy_ui/configurable/report_log.rb
101
102
  - lib/daisy_ui/countdown.rb
102
103
  - lib/daisy_ui/diff.rb
103
104
  - lib/daisy_ui/divider.rb
@@ -141,11 +142,13 @@ files:
141
142
  - lib/daisy_ui/range.rb
142
143
  - lib/daisy_ui/rating.rb
143
144
  - lib/daisy_ui/select.rb
145
+ - lib/daisy_ui/shareable.rb
144
146
  - lib/daisy_ui/skeleton.rb
145
147
  - lib/daisy_ui/stack.rb
146
148
  - lib/daisy_ui/stat.rb
147
149
  - lib/daisy_ui/status.rb
148
150
  - lib/daisy_ui/steps.rb
151
+ - lib/daisy_ui/store.rb
149
152
  - lib/daisy_ui/sub_menu.rb
150
153
  - lib/daisy_ui/swap.rb
151
154
  - lib/daisy_ui/tab.rb