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 +4 -4
- data/CHANGELOG.md +9 -0
- data/benchmark/benchmark.rb +75 -0
- data/lib/tailwind_merge/class_group_utils.rb +8 -7
- data/lib/tailwind_merge/parse_class_name.rb +30 -9
- data/lib/tailwind_merge/validators.rb +2 -2
- data/lib/tailwind_merge/version.rb +1 -1
- data/lib/tailwind_merge.rb +14 -11
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aadbe94d7d509ee795a2b34277fa7195f443d83081d2adaf88a11c07d2918302
|
|
4
|
+
data.tar.gz: a6b99aea1b699341b88d7052f7e7c6b588326d74decca79a3c0caaf256042107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
modifiers << class_name
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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) {
|
data/lib/tailwind_merge.rb
CHANGED
|
@@ -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::
|
|
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
|
-
|
|
46
|
-
return "" if
|
|
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
|
-
|
|
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
|
-
|
|
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, @
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
81
|
-
source_code_uri: https://github.com/gjtorikian/tailwind_merge/tree/v1.5.
|
|
82
|
-
changelog_uri: https://github.com/gjtorikian/tailwind_merge/blob/v1.5.
|
|
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.
|
|
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: []
|