tailwind_merge 1.5.1 → 1.5.3

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: 2be310d25aa48823e2782965b3d44202a26986ad59aaaf5672867e432c06dc15
4
- data.tar.gz: 17e4ef04d443ab193c6b5b468008f2e0d1a9db1aaa6c37533e296531505c6bea
3
+ metadata.gz: '059b8e52b3a7bc182e6cae5db2a2c8dee46aa1d1176b6b7d26dd7f2313f566cc'
4
+ data.tar.gz: c94346250084798c59da34357f9e16acc058943eb5e64f759f87b06701adac7c
5
5
  SHA512:
6
- metadata.gz: 260b41ff06162e8b0b25f65f269f978f3d947b19f418b2d77d8b6fc760a684826a3a7a9246aae0c86aae6791a21e87fef46961eae552e3a88bd495699eef057e
7
- data.tar.gz: 46e891d347c8d5e73af2d3a92f6f4bb8e7c1e0b63b6fb32305183c7d36834cc8870a7b7e0f1dfb8c529ab0726ecf92d5fb2c24620b6a0320bd035fc60980056c
6
+ metadata.gz: 61dc28329c357f7b1bb060b499c56d6d419672f2ccade9a3cb8442ae1c77da2f3c9c9526894c836294ef6128a78041adbd1b0c291b9c6b7fa33ef34b527400f1
7
+ data.tar.gz: 76ea9d360488b4945ce4b2a3ac642fdd20a0cd0eee20b85a3baec2f363ce3dcddbbd3dc9a6be2f7207d30a5776aeff328df792b742770e5b2d1c63867a076eac
data/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## [1.5.3](https://github.com/gjtorikian/tailwind_merge/compare/v1.5.2...v1.5.3) (2026-08-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **config:** classify shadow-inner into the shadow class group ([99bccc2](https://github.com/gjtorikian/tailwind_merge/commit/99bccc2b66d5a6e94735515676102534a3f29e37))
6
+
7
+ ### Performance Improvements
8
+
9
+ * **parse:** skip modifiers array alloc for no-modifier classes ([0ee620e](https://github.com/gjtorikian/tailwind_merge/commit/0ee620e7e464074fb490fd363afaa5b404a4faf4))
10
+
11
+ ### Miscellaneous Chores
12
+
13
+ * trigger release-please run ([65e80ae](https://github.com/gjtorikian/tailwind_merge/commit/65e80ae41d08e813c0d6ce9317aa235454bfc935))
14
+ * **config:** deep-freeze DEFAULTS to prevent global-state mutation ([9bf4778](https://github.com/gjtorikian/tailwind_merge/commit/9bf47789b778b4d1e0503e9b69ce30d407826483))
15
+
16
+
17
+ # [v1.5.2] - 06-07-2026
18
+ ## What's Changed
19
+ * fix: thread-safety of Merger's internal LRU cache by @svyatov in https://github.com/gjtorikian/tailwind_merge/pull/79
20
+ * perf: reduce allocations in merge hot path (~35% faster, ~56% fewer allocations) by @svyatov in https://github.com/gjtorikian/tailwind_merge/pull/80
21
+
22
+ ## New Contributors
23
+ * @svyatov made their first contribution in https://github.com/gjtorikian/tailwind_merge/pull/79
24
+
25
+ **Full Changelog**: https://github.com/gjtorikian/tailwind_merge/compare/v1.5.1...v1.5.2
1
26
  # [v1.5.1] - 18-05-2026
2
27
  ## What's Changed
3
28
  * Fix memory leak when passing in config by @ibrahima in https://github.com/gjtorikian/tailwind_merge/pull/77
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmark for the cache-miss merge path.
4
+ #
5
+ # Usage: ruby benchmark/benchmark.rb
6
+ #
7
+ # Measures `merge_class_list` directly: `merge` memoizes whole input strings
8
+ # in an LRU cache, so benchmarking it with a fixed corpus would mostly
9
+ # measure a Hash lookup. The uncached path is what runs for every
10
+ # first-seen class string in a real app.
11
+
12
+ require "bundler/inline"
13
+
14
+ gemfile do
15
+ source "https://rubygems.org"
16
+ gem "benchmark-ips"
17
+ gem "memory_profiler"
18
+ gem "sin_lru_redux"
19
+ end
20
+
21
+ require_relative "../lib/tailwind_merge"
22
+
23
+ CORPUS = [
24
+ # simple conflicts
25
+ "px-2 px-4",
26
+ "text-sm text-lg font-bold",
27
+ "block inline flex",
28
+ # modifiers
29
+ "hover:focus:!bg-red-500 hover:focus:!bg-blue-600",
30
+ "md:hover:underline md:hover:no-underline",
31
+ "dark:sm:hover:bg-gray-800 dark:sm:hover:bg-gray-900",
32
+ # postfix modifiers
33
+ "bg-red-500/50 bg-red-500/75",
34
+ "text-white/90 leading-7",
35
+ # arbitrary values / properties / variables
36
+ "p-[2px] p-[4px]",
37
+ "[mask-type:luminance] [mask-type:alpha]",
38
+ "bg-(--brand-color) bg-red-500",
39
+ "content-['hello'] content-['world']",
40
+ # non-tailwind classes mixed in
41
+ "my-custom-class px-2 another-class px-4",
42
+ # long realistic string (shadcn-style button + overrides)
43
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md " \
44
+ "text-sm font-medium transition-colors focus-visible:outline-none " \
45
+ "focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none " \
46
+ "disabled:opacity-50 bg-primary text-primary-foreground shadow " \
47
+ "hover:bg-primary/90 h-9 px-4 py-2 rounded-lg px-6 text-base w-full",
48
+ ].freeze
49
+
50
+ merger = TailwindMerge::Merger.new
51
+
52
+ # sanity check: outputs must be non-empty
53
+ CORPUS.each do |s|
54
+ result = merger.send(:merge_class_list, s)
55
+ raise "unexpected empty result for #{s.inspect}" if result.empty?
56
+ end
57
+
58
+ puts "ruby #{RUBY_VERSION} (#{RUBY_ENGINE}), tailwind_merge #{TailwindMerge::VERSION}"
59
+ puts
60
+
61
+ Benchmark.ips do |x|
62
+ x.report("merge_class_list (corpus of #{CORPUS.size})") do
63
+ CORPUS.each { |s| merger.send(:merge_class_list, s) }
64
+ end
65
+ x.report("merge_class_list (long string)") do
66
+ merger.send(:merge_class_list, CORPUS.last)
67
+ end
68
+ end
69
+
70
+ puts
71
+ report = MemoryProfiler.report do
72
+ CORPUS.each { |s| merger.send(:merge_class_list, s) }
73
+ end
74
+ puts "allocations for one corpus pass: " \
75
+ "#{report.total_allocated} objects, #{report.total_allocated_memsize} bytes"
@@ -23,25 +23,26 @@ module TailwindMerge
23
23
  get_group_recursive(class_parts, @class_map) || get_group_id_for_arbitrary_property(class_name)
24
24
  end
25
25
 
26
- def get_group_recursive(class_parts, class_part_object)
27
- return class_part_object[:class_group_id] if class_parts.empty?
26
+ def get_group_recursive(class_parts, class_part_object, index = 0)
27
+ return class_part_object[:class_group_id] if index == class_parts.length
28
28
 
29
- current_class_part = class_parts.first
29
+ current_class_part = class_parts[index]
30
30
 
31
31
  next_class_part_object = class_part_object[:next_part][current_class_part]
32
32
 
33
33
  if next_class_part_object
34
- class_group_from_next_class_part = get_group_recursive(class_parts.drop(1), next_class_part_object)
34
+ class_group_from_next_class_part = get_group_recursive(class_parts, next_class_part_object, index + 1)
35
35
  return class_group_from_next_class_part if class_group_from_next_class_part
36
36
  end
37
37
 
38
38
  return if class_part_object[:validators].empty?
39
39
 
40
- class_rest = class_parts.join(CLASS_PART_SEPARATOR)
40
+ class_rest = index.zero? ? class_parts.join(CLASS_PART_SEPARATOR) : class_parts[index..].join(CLASS_PART_SEPARATOR)
41
41
 
42
+ # Theme procs are expanded at trie-build time (see process_classes_recursively),
43
+ # so validators here are always plain value validators.
42
44
  result = class_part_object[:validators].find do |v|
43
- validator = v[:validator]
44
- from_theme?(validator) ? validator.call(@config) : validator.call(class_rest)
45
+ v[:validator].call(class_rest)
45
46
  end
46
47
 
47
48
  result&.fetch(:class_group_id, nil)
@@ -6,6 +6,19 @@ module TailwindMerge
6
6
  module Config
7
7
  include Validators
8
8
 
9
+ class << self
10
+ # Recursively freezes nested Hash/Array structures so shared config
11
+ # constants can't be mutated in place. Keys are strings/symbols and are
12
+ # already immutable, so only values need walking.
13
+ private def deep_freeze(object)
14
+ case object
15
+ when Hash then object.each_value { |value| deep_freeze(value) }
16
+ when Array then object.each { |value| deep_freeze(value) }
17
+ end
18
+ object.freeze
19
+ end
20
+ end
21
+
9
22
  THEME_KEYS = [
10
23
  "color",
11
24
  "font",
@@ -1432,6 +1445,8 @@ module TailwindMerge
1432
1445
  "shadow" => [
1433
1446
  # Deprecated since Tailwind CSS v4.0.0
1434
1447
  "",
1448
+ # Deprecated since Tailwind CSS v4.0.0
1449
+ "inner",
1435
1450
  "none",
1436
1451
  THEME_SHADOW,
1437
1452
  IS_ARBITRARY_VARIABLE_SHADOW,
@@ -2441,7 +2456,8 @@ module TailwindMerge
2441
2456
  "placeholder",
2442
2457
  "selection",
2443
2458
  ],
2444
- }.freeze
2459
+ }
2460
+ deep_freeze(DEFAULTS)
2445
2461
 
2446
2462
  def merge_config(incoming_config)
2447
2463
  extended_config = TailwindMerge::Config::DEFAULTS.dup
@@ -6,9 +6,17 @@ module TailwindMerge
6
6
 
7
7
  module ParseClassName
8
8
  IMPORTANT_MODIFIER = "!"
9
+ EMPTY_MODIFIERS = [].freeze
9
10
  MODIFIER_SEPARATOR = ":"
10
11
  MODIFIER_SEPARATOR_LENGTH = MODIFIER_SEPARATOR.length
11
12
 
13
+ MODIFIER_SEPARATOR_BYTE = MODIFIER_SEPARATOR.ord
14
+ POSTFIX_SEPARATOR_BYTE = "/".ord
15
+ OPEN_BRACKET_BYTE = "[".ord
16
+ CLOSE_BRACKET_BYTE = "]".ord
17
+ OPEN_PAREN_BYTE = "(".ord
18
+ CLOSE_PAREN_BYTE = ")".ord
19
+
12
20
  ##
13
21
  # Parse class name into parts.
14
22
  #
@@ -22,7 +30,7 @@ module TailwindMerge
22
30
  else
23
31
  return TailwindClass.new(
24
32
  is_external: true,
25
- modifiers: [],
33
+ modifiers: EMPTY_MODIFIERS,
26
34
  has_important_modifier: false,
27
35
  base_class_name: class_name,
28
36
  maybe_postfix_modifier_position: nil,
@@ -30,32 +38,48 @@ module TailwindMerge
30
38
  end
31
39
  end
32
40
 
33
- modifiers = []
41
+ # Stays nil until the first modifier separator, so the common no-modifier
42
+ # class (`px-2`, `block`) never allocates an array.
43
+ modifiers = nil
34
44
 
35
45
  bracket_depth = 0
36
46
  paren_depth = 0
37
47
  modifier_start = 0
38
48
  postfix_modifier_position = nil
39
49
 
40
- class_name.each_char.with_index do |char, index|
50
+ # Byte-wise scan: all separators are ASCII, so byte positions are safe
51
+ # for multibyte class names (UTF-8 continuation bytes never match ASCII).
52
+ # Positions produced here (including maybe_postfix_modifier_position)
53
+ # are byte offsets and must be consumed with String#byteslice.
54
+ index = 0
55
+ size = class_name.bytesize
56
+ while index < size
57
+ byte = class_name.getbyte(index)
58
+
41
59
  if bracket_depth.zero? && paren_depth.zero?
42
- if char == MODIFIER_SEPARATOR
43
- modifiers << class_name[modifier_start...index]
60
+ if byte == MODIFIER_SEPARATOR_BYTE
61
+ (modifiers ||= []) << class_name.byteslice(modifier_start, index - modifier_start)
44
62
  modifier_start = index + MODIFIER_SEPARATOR_LENGTH
63
+ index += 1
45
64
  next
46
- elsif char == "/"
65
+ elsif byte == POSTFIX_SEPARATOR_BYTE
47
66
  postfix_modifier_position = index
67
+ index += 1
48
68
  next
49
69
  end
50
70
  end
51
71
 
52
- bracket_depth += 1 if char == "["
53
- bracket_depth -= 1 if char == "]"
54
- paren_depth += 1 if char == "("
55
- paren_depth -= 1 if char == ")"
72
+ case byte
73
+ when OPEN_BRACKET_BYTE then bracket_depth += 1
74
+ when CLOSE_BRACKET_BYTE then bracket_depth -= 1
75
+ when OPEN_PAREN_BYTE then paren_depth += 1
76
+ when CLOSE_PAREN_BYTE then paren_depth -= 1
77
+ end
78
+
79
+ index += 1
56
80
  end
57
81
 
58
- base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name[modifier_start..]
82
+ base_class_name_with_important_modifier = modifiers ? class_name.byteslice(modifier_start, size - modifier_start) : class_name
59
83
 
60
84
  base_class_name, has_important_modifier = strip_important_modifier(base_class_name_with_important_modifier)
61
85
 
@@ -65,7 +89,7 @@ module TailwindMerge
65
89
 
66
90
  TailwindClass.new(
67
91
  is_external: false,
68
- modifiers:,
92
+ modifiers: modifiers || EMPTY_MODIFIERS,
69
93
  has_important_modifier:,
70
94
  base_class_name: base_class_name,
71
95
  maybe_postfix_modifier_position:,
@@ -97,7 +97,7 @@ module TailwindMerge
97
97
  }
98
98
 
99
99
  IS_ARBITRARY_VALUE = ->(value) {
100
- ARBITRARY_VALUE_REGEX.match(value)
100
+ ARBITRARY_VALUE_REGEX.match?(value)
101
101
  }
102
102
 
103
103
  IS_ARBITRARY_LENGTH = ->(value) {
@@ -129,7 +129,7 @@ module TailwindMerge
129
129
  }
130
130
 
131
131
  IS_ARBITRARY_VARIABLE = ->(value) {
132
- ARBITRARY_VARIABLE_REGEX.match(value)
132
+ ARBITRARY_VARIABLE_REGEX.match?(value)
133
133
  }
134
134
 
135
135
  IS_ARBITRARY_VARIABLE_LENGTH = ->(value) {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TailwindMerge
4
- VERSION = "1.5.1"
4
+ VERSION = "1.5.3"
5
5
  end
@@ -18,14 +18,13 @@ module TailwindMerge
18
18
  include ParseClassName
19
19
  include SortModifiers
20
20
 
21
- SPLIT_CLASSES_REGEX = /\s+/
22
-
23
21
  def initialize(config: {})
24
22
  @config = merge_config(config)
25
23
  @config[:important_modifier] = @config[:important_modifier].to_s
26
24
  @class_utils = TailwindMerge::ClassGroupUtils.new(@config)
27
- @cache = LruRedux::Cache.new(@config[:cache_size], @config[:ignore_empty_cache])
25
+ @cache = LruRedux::ThreadSafeCache.new(@config[:cache_size], @config[:ignore_empty_cache])
28
26
  @postfix_lookup_class_group_ids = build_postfix_lookup_class_group_ids(@config[:postfix_lookup_class_groups])
27
+ @order_sensitive_modifiers = Set.new(@config[:order_sensitive_modifiers]).freeze
29
28
  end
30
29
 
31
30
  def merge(classes)
@@ -42,14 +41,14 @@ module TailwindMerge
42
41
  # @example 'float'
43
42
  # @example 'hover:focus:bg-color'
44
43
  # @example 'md:!pr'
45
- trimmed = class_list.strip
46
- return "" if trimmed.empty?
44
+ class_names = class_list.split
45
+ return "" if class_names.empty?
47
46
 
48
47
  class_groups_in_conflict = Set.new
49
48
 
50
49
  merged_classes = []
51
50
 
52
- trimmed.split(SPLIT_CLASSES_REGEX).reverse_each do |original_class_name|
51
+ class_names.reverse_each do |original_class_name|
53
52
  result = parse_class_name(original_class_name, prefix: @config[:prefix])
54
53
  is_external = result.is_external
55
54
  modifiers = result.modifiers
@@ -65,7 +64,8 @@ module TailwindMerge
65
64
  has_postfix_modifier = maybe_postfix_modifier_position ? true : false
66
65
 
67
66
  if has_postfix_modifier
68
- base_class_name_without_postfix = base_class_name[0...maybe_postfix_modifier_position]
67
+ # maybe_postfix_modifier_position is a byte offset (see ParseClassName)
68
+ base_class_name_without_postfix = base_class_name.byteslice(0, maybe_postfix_modifier_position)
69
69
  class_group_id = @class_utils.class_group_id(base_class_name_without_postfix)
70
70
 
71
71
  if class_group_id && @postfix_lookup_class_group_ids[class_group_id]
@@ -97,10 +97,13 @@ module TailwindMerge
97
97
  has_postfix_modifier = false
98
98
  end
99
99
 
100
- variant_modifier = sort_modifiers(modifiers, @config[:order_sensitive_modifiers]).join(":")
100
+ variant_modifier = sort_modifiers(modifiers, @order_sensitive_modifiers).join(":")
101
101
 
102
102
  modifier_id = has_important_modifier ? "#{variant_modifier}#{IMPORTANT_MODIFIER}" : variant_modifier
103
- class_id = "#{modifier_id}#{class_group_id}"
103
+ has_modifier_id = !modifier_id.empty?
104
+ # `to_s` matches the previous interpolation behavior: group ids may be
105
+ # Symbols (custom configs) or Strings (defaults, where to_s is free)
106
+ class_id = has_modifier_id ? "#{modifier_id}#{class_group_id}" : class_group_id.to_s
104
107
 
105
108
  # Tailwind class omitted due to conflict
106
109
  next if class_groups_in_conflict.include?(class_id)
@@ -108,14 +111,14 @@ module TailwindMerge
108
111
  class_groups_in_conflict << class_id
109
112
 
110
113
  @class_utils.get_conflicting_class_group_ids(class_group_id, has_postfix_modifier).each do |conflicting_id|
111
- class_groups_in_conflict << "#{modifier_id}#{conflicting_id}"
114
+ class_groups_in_conflict << (has_modifier_id ? "#{modifier_id}#{conflicting_id}" : conflicting_id.to_s)
112
115
  end
113
116
 
114
117
  # Tailwind class not in conflict
115
118
  merged_classes << original_class_name
116
119
  end
117
120
 
118
- merged_classes.reverse.join(" ")
121
+ merged_classes.reverse!.join(" ")
119
122
  end
120
123
 
121
124
  private def build_postfix_lookup_class_group_ids(class_group_ids)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tailwind_merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garen J. Torikian
@@ -64,6 +64,7 @@ files:
64
64
  - LICENSE.txt
65
65
  - README.md
66
66
  - Rakefile
67
+ - benchmark/benchmark.rb
67
68
  - lib/tailwind_merge.rb
68
69
  - lib/tailwind_merge/class_group_utils.rb
69
70
  - lib/tailwind_merge/config.rb
@@ -73,15 +74,15 @@ files:
73
74
  - lib/tailwind_merge/version.rb
74
75
  - script/test
75
76
  - tailwind_merge.gemspec
76
- homepage: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.1
77
+ homepage: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.3
77
78
  licenses:
78
79
  - MIT
79
80
  metadata:
80
- homepage_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.1
81
- source_code_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.1
82
- changelog_uri: https://github.com/gjtorikian/tailwind_merge/blob/v1.5.1/CHANGELOG.md
81
+ homepage_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.3
82
+ source_code_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.3
83
+ changelog_uri: https://github.com/gjtorikian/tailwind_merge/blob/v1.5.3/CHANGELOG.md
83
84
  bug_tracker_uri: https://github.com/gjtorikian/tailwind_merge/issues
84
- documentation_uri: https://rubydoc.info/gems/tailwind_merge/1.5.1
85
+ documentation_uri: https://rubydoc.info/gems/tailwind_merge/1.5.3
85
86
  funding_uri: https://github.com/sponsors/gjtorikian
86
87
  rubygems_mfa_required: 'true'
87
88
  rdoc_options: []