tailwind_merge 1.5.1 → 1.5.2

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: aadbe94d7d509ee795a2b34277fa7195f443d83081d2adaf88a11c07d2918302
4
+ data.tar.gz: a6b99aea1b699341b88d7052f7e7c6b588326d74decca79a3c0caaf256042107
5
5
  SHA512:
6
- metadata.gz: 260b41ff06162e8b0b25f65f269f978f3d947b19f418b2d77d8b6fc760a684826a3a7a9246aae0c86aae6791a21e87fef46961eae552e3a88bd495699eef057e
7
- data.tar.gz: 46e891d347c8d5e73af2d3a92f6f4bb8e7c1e0b63b6fb32305183c7d36834cc8870a7b7e0f1dfb8c529ab0726ecf92d5fb2c24620b6a0320bd035fc60980056c
6
+ metadata.gz: bfadf225da1834890e98ee7b89202db8714b954c8cc077ad33eabdc1731f5617c85747aa1cd8641d659ea45b95f61b8ea9e0c00d1b852d3cc4b99ae4210850f4
7
+ data.tar.gz: c5b300134da62796aaa0697e7ad341bd223139cb07a5b51a556de469692271c2750ad52d1c1ec032968dcc50f295c4e68d5c7ef502c81452ab0b4ec23baf214d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ # [v1.5.2] - 06-07-2026
2
+ ## What's Changed
3
+ * fix: thread-safety of Merger's internal LRU cache by @svyatov in https://github.com/gjtorikian/tailwind_merge/pull/79
4
+ * perf: reduce allocations in merge hot path (~35% faster, ~56% fewer allocations) by @svyatov in https://github.com/gjtorikian/tailwind_merge/pull/80
5
+
6
+ ## New Contributors
7
+ * @svyatov made their first contribution in https://github.com/gjtorikian/tailwind_merge/pull/79
8
+
9
+ **Full Changelog**: https://github.com/gjtorikian/tailwind_merge/compare/v1.5.1...v1.5.2
1
10
  # [v1.5.1] - 18-05-2026
2
11
  ## What's Changed
3
12
  * 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)
@@ -9,6 +9,13 @@ module TailwindMerge
9
9
  MODIFIER_SEPARATOR = ":"
10
10
  MODIFIER_SEPARATOR_LENGTH = MODIFIER_SEPARATOR.length
11
11
 
12
+ MODIFIER_SEPARATOR_BYTE = MODIFIER_SEPARATOR.ord
13
+ POSTFIX_SEPARATOR_BYTE = "/".ord
14
+ OPEN_BRACKET_BYTE = "[".ord
15
+ CLOSE_BRACKET_BYTE = "]".ord
16
+ OPEN_PAREN_BYTE = "(".ord
17
+ CLOSE_PAREN_BYTE = ")".ord
18
+
12
19
  ##
13
20
  # Parse class name into parts.
14
21
  #
@@ -37,25 +44,39 @@ module TailwindMerge
37
44
  modifier_start = 0
38
45
  postfix_modifier_position = nil
39
46
 
40
- class_name.each_char.with_index do |char, index|
47
+ # Byte-wise scan: all separators are ASCII, so byte positions are safe
48
+ # for multibyte class names (UTF-8 continuation bytes never match ASCII).
49
+ # Positions produced here (including maybe_postfix_modifier_position)
50
+ # are byte offsets and must be consumed with String#byteslice.
51
+ index = 0
52
+ size = class_name.bytesize
53
+ while index < size
54
+ byte = class_name.getbyte(index)
55
+
41
56
  if bracket_depth.zero? && paren_depth.zero?
42
- if char == MODIFIER_SEPARATOR
43
- modifiers << class_name[modifier_start...index]
57
+ if byte == MODIFIER_SEPARATOR_BYTE
58
+ modifiers << class_name.byteslice(modifier_start, index - modifier_start)
44
59
  modifier_start = index + MODIFIER_SEPARATOR_LENGTH
60
+ index += 1
45
61
  next
46
- elsif char == "/"
62
+ elsif byte == POSTFIX_SEPARATOR_BYTE
47
63
  postfix_modifier_position = index
64
+ index += 1
48
65
  next
49
66
  end
50
67
  end
51
68
 
52
- bracket_depth += 1 if char == "["
53
- bracket_depth -= 1 if char == "]"
54
- paren_depth += 1 if char == "("
55
- paren_depth -= 1 if char == ")"
69
+ case byte
70
+ when OPEN_BRACKET_BYTE then bracket_depth += 1
71
+ when CLOSE_BRACKET_BYTE then bracket_depth -= 1
72
+ when OPEN_PAREN_BYTE then paren_depth += 1
73
+ when CLOSE_PAREN_BYTE then paren_depth -= 1
74
+ end
75
+
76
+ index += 1
56
77
  end
57
78
 
58
- base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name[modifier_start..]
79
+ base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name.byteslice(modifier_start, size - modifier_start)
59
80
 
60
81
  base_class_name, has_important_modifier = strip_important_modifier(base_class_name_with_important_modifier)
61
82
 
@@ -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.2"
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.2
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.2
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.2
82
+ source_code_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.2
83
+ changelog_uri: https://github.com/gjtorikian/tailwind_merge/blob/v1.5.2/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.2
85
86
  funding_uri: https://github.com/sponsors/gjtorikian
86
87
  rubygems_mfa_required: 'true'
87
88
  rdoc_options: []