Skip to content

Commit 8cec196

Browse files
committed
fix: twMerge adapter resolution for browser-safe builds
1 parent 3eb635d commit 8cec196

File tree

9 files changed

+112
-23
lines changed

9 files changed

+112
-23
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tailwind-variant-v3": patch
3+
---
4+
5+
Fix twMerge adapter resolution for browser-safe builds and keep default merge behavior when overriding config.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

packages-runtime/tailwind-variant-v3/src/merge.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,57 @@ import type { TailwindMergeConfig } from './constants'
22
import type { ClassValue, TailwindMergeAdapter, TVConfig, TWMConfig } from './types'
33
import { defaultConfig } from './constants'
44
import { isEmptyObject, isEqual } from './utils'
5-
// eslint-disable-next-line perfectionist/sort-imports
6-
import { createRequire } from 'node:module'
75

86
export type ClassMerger = (...classes: ClassValue[]) => string | undefined
97

108
const MERGE_MODULE_IDS = ['tailwind-merge'] as const
9+
// Keep the ESM output browser-friendly by avoiding static Node built-in imports.
1110
const requireFromThisModule = (() => {
12-
try {
13-
return createRequire(import.meta.url)
14-
}
15-
catch {
11+
const moduleRef = globalThis.module as unknown as {
12+
createRequire?: (url: string | URL) => NodeRequire
13+
require?: NodeRequire
14+
} | undefined
15+
16+
if (!moduleRef) {
1617
return null
1718
}
19+
20+
if (typeof moduleRef.createRequire === 'function') {
21+
try {
22+
const moduleUrl = new URL(import.meta.url)
23+
moduleUrl.search = ''
24+
moduleUrl.hash = ''
25+
return moduleRef.createRequire(moduleUrl)
26+
}
27+
catch {
28+
return null
29+
}
30+
}
31+
32+
const moduleCtor = (moduleRef as { constructor?: { createRequire?: (url: string | URL) => NodeRequire } }).constructor
33+
34+
if (typeof moduleCtor?.createRequire === 'function') {
35+
try {
36+
const moduleUrl = new URL(import.meta.url)
37+
moduleUrl.search = ''
38+
moduleUrl.hash = ''
39+
return moduleCtor.createRequire(moduleUrl)
40+
}
41+
catch {
42+
return null
43+
}
44+
}
45+
46+
if (typeof moduleRef.require === 'function') {
47+
try {
48+
return moduleRef.require.bind(moduleRef)
49+
}
50+
catch {
51+
return null
52+
}
53+
}
54+
55+
return null
1856
})()
1957

2058
let cachedTwMerge: ((className: string) => string) | null = null
@@ -96,7 +134,7 @@ function createTailwindMerge(adapter: TailwindMergeAdapter | null) {
96134
return adapter.twMerge
97135
}
98136

99-
const activeMergeConfig = cachedTwMergeConfig as Record<string, any>
137+
const activeMergeConfig = cachedTwMergeConfig as NonNullable<TWMConfig['twMergeConfig']>
100138

101139
return adapter.extendTailwindMerge({
102140
...activeMergeConfig,
@@ -178,11 +216,13 @@ export function cn<T extends ClassValue[]>(...classes: T) {
178216
return undefined
179217
}
180218

181-
if (!config.twMerge) {
219+
const resolvedConfig = config === defaultConfig ? config : { ...defaultConfig, ...config }
220+
221+
if (!resolvedConfig.twMerge) {
182222
return className
183223
}
184224

185-
const adapter = resolveMergeAdapter(config)
225+
const adapter = resolveMergeAdapter(resolvedConfig)
186226
const shouldRefreshAdapter = adapter !== cachedActiveAdapter
187227

188228
if (!cachedTwMerge || didTwMergeConfigChange || shouldRefreshAdapter) {

packages-runtime/tailwind-variant-v3/src/tv.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CreateTVFactory, TV, TVConfig } from './types'
1+
import type { ClassValue, CreateTVFactory, TV, TVConfig } from './types'
22
import { defaultConfig } from './constants'
33
import { hasSlotOverrides, hasVariantOverrides } from './helpers'
44
import { cn, cnBase, createClassMerger, updateTailwindMergeConfig } from './merge'
@@ -13,6 +13,37 @@ import {
1313
resolveResponsiveSettings,
1414
} from './variants'
1515

16+
interface TVExtendShape {
17+
base?: ClassValue
18+
variants?: Record<string, any>
19+
defaultVariants?: Record<string, any>
20+
slots?: Record<string, any>
21+
compoundVariants?: any[]
22+
compoundSlots?: any[]
23+
}
24+
25+
type TVExtend = TVExtendShape | null
26+
27+
interface TVOptions {
28+
base?: ClassValue
29+
extend?: TVExtend
30+
slots?: Record<string, any> | undefined
31+
variants?: Record<string, any>
32+
compoundVariants?: any[]
33+
compoundSlots?: any[]
34+
defaultVariants?: Record<string, any>
35+
}
36+
37+
interface TVProps extends Record<string, any> {
38+
class?: ClassValue
39+
className?: ClassValue
40+
}
41+
42+
interface SlotPropsArg extends Record<string, any> {
43+
class?: ClassValue
44+
className?: ClassValue
45+
}
46+
1647
function mergeSlotDefinitions(
1748
baseSlots: Record<string, any>,
1849
overrideSlots: Record<string, any>,
@@ -39,7 +70,7 @@ function assertArray(value: unknown, propName: string): asserts value is any[] {
3970
}
4071
}
4172

42-
export function tvImplementation(options: Record<string, any>, configProp?: TVConfig) {
73+
export function tvImplementation(options: TVOptions, configProp?: TVConfig) {
4374
const {
4475
extend = null,
4576
slots: slotProps = {},
@@ -51,7 +82,9 @@ export function tvImplementation(options: Record<string, any>, configProp?: TVCo
5182

5283
const config: TVConfig = { ...defaultConfig, ...configProp }
5384

54-
const base = extend?.base ? cnBase(extend.base, options?.base) : options?.base
85+
const base = extend?.base
86+
? cnBase(extend.base, options?.base)
87+
: options?.base
5588

5689
const variants = extend?.variants && !isEmptyObject(extend.variants)
5790
? mergeObjects(variantsProps, extend.variants)
@@ -67,14 +100,14 @@ export function tvImplementation(options: Record<string, any>, configProp?: TVCo
67100
const isExtendedSlotsEmpty = isEmptyObject(extendSlots)
68101
const hasOwnSlots = !isEmptyObject(slotProps)
69102

70-
const componentSlots = hasOwnSlots
103+
const componentSlots: Record<string, any> = hasOwnSlots
71104
? {
72105
base: cnBase(options?.base, isExtendedSlotsEmpty && extend?.base),
73106
...slotProps,
74107
}
75108
: {}
76109

77-
const slots = isExtendedSlotsEmpty
110+
const slots: Record<string, any> = isExtendedSlotsEmpty
78111
? componentSlots
79112
: mergeSlotDefinitions(
80113
{ ...extendSlots },
@@ -93,12 +126,12 @@ export function tvImplementation(options: Record<string, any>, configProp?: TVCo
93126
let cachedDefaultResult: any
94127
let hasCachedDefaultResult = false
95128

96-
const component = (propsParam?: Record<string, any>) => {
129+
const component = (propsParam?: TVProps) => {
97130
if (!propsParam && hasCachedDefaultResult) {
98131
return cachedDefaultResult
99132
}
100133

101-
const props = propsParam ?? {}
134+
const props: Record<string, any> = propsParam ?? {}
102135

103136
assertArray(compoundVariants, 'compoundVariants')
104137
assertArray(compoundSlots, 'compoundSlots')
@@ -116,7 +149,9 @@ export function tvImplementation(options: Record<string, any>, configProp?: TVCo
116149
config,
117150
props,
118151
variantResponsiveSettings: responsiveState.variantResponsiveSettings,
119-
globalResponsiveSetting: responsiveState.globalResponsiveSetting,
152+
...(responsiveState.globalResponsiveSetting === undefined
153+
? {}
154+
: { globalResponsiveSetting: responsiveState.globalResponsiveSetting }),
120155
})
121156

122157
const baseVariantClassNames = getVariantClassNames(context)
@@ -157,12 +192,15 @@ export function tvImplementation(options: Record<string, any>, configProp?: TVCo
157192
)
158193
}
159194

160-
const slotsFns: Record<string, any> = {}
195+
const slotsFns: Record<
196+
string,
197+
(slotPropsArg?: SlotPropsArg) => ReturnType<typeof mergeClasses>
198+
> = {}
161199

162200
for (const slotKey of slotKeys) {
163201
const cached = baseSlotOutputs.get(slotKey)
164202

165-
slotsFns[slotKey] = (slotPropsArg?: Record<string, any>) => {
203+
slotsFns[slotKey] = (slotPropsArg?: SlotPropsArg) => {
166204
if (!slotPropsArg || !hasSlotOverrides(slotPropsArg)) {
167205
return cached
168206
}

packages-runtime/tailwind-variant-v3/src/variants.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ export function resolveResponsiveSettings(
2626
if (Array.isArray(responsiveConfig) || typeof responsiveConfig === 'boolean') {
2727
globalResponsiveSetting = responsiveConfig
2828
}
29-
else if (responsiveConfig && typeof responsiveConfig === 'object') {
29+
else if (responsiveConfig && typeof responsiveConfig === 'object' && !Array.isArray(responsiveConfig)) {
30+
const responsiveMap = responsiveConfig as Record<string, boolean | string[] | undefined>
31+
3032
for (const key of variantKeys) {
31-
if (responsiveConfig[key] !== undefined) {
32-
variantResponsiveSettings[key] = responsiveConfig[key] as boolean | string[]
33+
if (responsiveMap[key] !== undefined) {
34+
variantResponsiveSettings[key] = responsiveMap[key] as boolean | string[]
3335
}
3436
}
3537
}
@@ -337,7 +339,7 @@ export function getCompoundVariantClassNamesBySlot(
337339
return compoundClassNames
338340
}
339341

340-
const result: Record<string, any> = {}
342+
const result: { base?: any } & Record<string, any> = {}
341343

342344
for (const className of compoundClassNames) {
343345
if (typeof className === 'string') {

packages-runtime/tailwind-variant-v3/test/matchers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { extendTailwindMerge, twMerge } from 'tailwind-merge'
12
import { expect } from 'vitest'
3+
import { defaultConfig } from '../src/index'
4+
5+
defaultConfig.twMergeAdapter = { extendTailwindMerge, twMerge }
26

37
function parseClasses(result: string | string[]) {
48
return (typeof result === 'string' ? result.split(' ') : result).slice().sort()

0 commit comments

Comments
 (0)