diff --git a/docs/demo/css-var.md b/docs/demo/css-var.md new file mode 100644 index 00000000..f2692b42 --- /dev/null +++ b/docs/demo/css-var.md @@ -0,0 +1,8 @@ +--- +title: CSS Variables +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/components/Button.tsx b/docs/examples/components/Button.tsx index eb038bc3..6b8d1459 100644 --- a/docs/examples/components/Button.tsx +++ b/docs/examples/components/Button.tsx @@ -1,5 +1,5 @@ import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs'; -import { useStyleRegister } from '@ant-design/cssinjs'; +import { unit, useStyleRegister } from '@ant-design/cssinjs'; import classNames from 'classnames'; import React from 'react'; import type { DerivativeToken } from './theme'; @@ -14,6 +14,7 @@ const genSharedButtonStyle = ( borderColor: token.borderColor, borderWidth: token.borderWidth, borderRadius: token.borderRadius, + lineHeight: token.lineHeight, cursor: 'pointer', @@ -65,7 +66,7 @@ const genPrimaryButtonStyle = ( ): CSSInterpolation => genSolidButtonStyle(prefixCls, token, () => ({ backgroundColor: token.primaryColor, - border: `${token.borderWidth}px solid ${token.primaryColor}`, + border: `${unit(token.borderWidth)} solid ${token.primaryColor}`, color: token.reverseTextColor, '&:hover': { @@ -83,7 +84,7 @@ const genGhostButtonStyle = ( [`.${prefixCls}`]: { backgroundColor: 'transparent', color: token.primaryColor, - border: `${token.borderWidth}px solid ${token.primaryColor}`, + border: `${unit(token.borderWidth)} solid ${token.primaryColor}`, '&:hover': { borderColor: token.primaryColor, @@ -102,7 +103,7 @@ const Button = ({ className, type, ...restProps }: ButtonProps) => { const prefixCls = 'ant-btn'; // 【自定义】制造样式 - const [theme, token, hashId] = useToken(); + const [theme, token, hashId, cssVarKey] = useToken(); // default 添加默认样式选择器后可以省很多冲突解决问题 const defaultCls = `${prefixCls}-default`; @@ -129,7 +130,7 @@ const Button = ({ className, type, ...restProps }: ButtonProps) => { return wrapSSR( + + + + + +
+ + + + + + + + + )} + + ); +} diff --git a/docs/examples/ssr-hydrate-file.tsx b/docs/examples/ssr-hydrate-file.tsx index dbd60e53..4ba2fc07 100644 --- a/docs/examples/ssr-hydrate-file.tsx +++ b/docs/examples/ssr-hydrate-file.tsx @@ -2,7 +2,7 @@ import { createCache, StyleProvider } from '@ant-design/cssinjs'; import React from 'react'; import { hydrateRoot } from 'react-dom/client'; import { Demo } from './ssr-advanced'; -import './ssr-hydrate-file.css'; +// import './ssr-hydrate-file.css'; // Copy from `ssr-advanced-hydrate.tsx` const HTML = ` diff --git a/package.json b/package.json index e8a40e9b..064b817c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-design/cssinjs", - "version": "1.17.2", + "version": "1.18.0-alpha.1", "description": "Component level cssinjs resolution for antd", "keywords": [ "react", @@ -25,11 +25,12 @@ "license": "MIT", "scripts": { "start": "dumi dev", + "dev": "father dev", "docs:build": "dumi build", "docs:deploy": "gh-pages -d .doc", "compile": "father build", "gh-pages": "npm run docs:build && npm run docs:deploy", - "prepublishOnly": "npm run compile && np --yolo --no-publish", + "prepublishOnly": "npm run compile && np --yolo --no-publish --branch=next --tag=next", "postpublish": "npm run gh-pages", "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", diff --git a/src/extractStyle.ts b/src/extractStyle.ts new file mode 100644 index 00000000..871a0c66 --- /dev/null +++ b/src/extractStyle.ts @@ -0,0 +1,83 @@ +import type Cache from './Cache'; +import { + extract as tokenExtractStyle, + TOKEN_PREFIX, +} from './hooks/useCacheToken'; +import { + CSS_VAR_PREFIX, + extract as cssVarExtractStyle, +} from './hooks/useCSSVarRegister'; +import { + extract as styleExtractStyle, + STYLE_PREFIX, +} from './hooks/useStyleRegister'; +import { toStyleStr } from './util'; +import { + ATTR_CACHE_MAP, + serialize as serializeCacheMap, +} from './util/cacheMapUtil'; + +const ExtractStyleFns = { + [STYLE_PREFIX]: styleExtractStyle, + [TOKEN_PREFIX]: tokenExtractStyle, + [CSS_VAR_PREFIX]: cssVarExtractStyle, +}; + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +export default function extractStyle(cache: Cache, plain = false) { + const matchPrefixRegexp = new RegExp( + `^(${Object.keys(ExtractStyleFns).join('|')})%`, + ); + + // prefix with `style` is used for `useStyleRegister` to cache style context + const styleKeys = Array.from(cache.cache.keys()).filter((key) => + matchPrefixRegexp.test(key), + ); + + // Common effect styles like animation + const effectStyles: Record = {}; + + // Mapping of cachePath to style hash + const cachePathMap: Record = {}; + + let styleText = ''; + + styleKeys + .map<[number, string] | null>((key) => { + const cachePath = key.replace(matchPrefixRegexp, '').replace(/%/g, '|'); + const [prefix] = key.split('%'); + const extractFn = ExtractStyleFns[prefix as keyof typeof ExtractStyleFns]; + const extractedStyle = extractFn(cache.cache.get(key)![1], effectStyles, { + plain, + }); + if (!extractedStyle) { + return null; + } + const [order, styleId, styleStr] = extractedStyle; + if (key.startsWith('style')) { + cachePathMap[cachePath] = styleId; + } + return [order, styleStr]; + }) + .filter(isNotNull) + .sort(([o1], [o2]) => o1 - o2) + .forEach(([, style]) => { + styleText += style; + }); + + // ==================== Fill Cache Path ==================== + styleText += toStyleStr( + `.${ATTR_CACHE_MAP}{content:"${serializeCacheMap(cachePathMap)}";}`, + undefined, + undefined, + { + [ATTR_CACHE_MAP]: ATTR_CACHE_MAP, + }, + plain, + ); + + return styleText; +} diff --git a/src/hooks/useCSSVarRegister.ts b/src/hooks/useCSSVarRegister.ts new file mode 100644 index 00000000..b14a2f64 --- /dev/null +++ b/src/hooks/useCSSVarRegister.ts @@ -0,0 +1,117 @@ +import { removeCSS, updateCSS } from 'rc-util/lib/Dom/dynamicCSS'; +import { useContext } from 'react'; +import StyleContext, { + ATTR_MARK, + ATTR_TOKEN, + CSS_IN_JS_INSTANCE, +} from '../StyleContext'; +import { isClientSide, toStyleStr } from '../util'; +import type { TokenWithCSSVar } from '../util/css-variables'; +import { transformToken } from '../util/css-variables'; +import type { ExtractStyle } from './useGlobalCache'; +import useGlobalCache from './useGlobalCache'; +import { uniqueHash } from './useStyleRegister'; + +export const CSS_VAR_PREFIX = 'cssVar'; + +type CSSVarCacheValue = [ + cssVarToken: TokenWithCSSVar, + cssVarStr: string, + styleId: string, + cssVarKey: string, +]; + +const useCSSVarRegister = >( + config: { + path: string[]; + key: string; + prefix?: string; + unitless?: Record; + ignore?: Record; + scope?: string; + token: any; + }, + fn: () => T, +) => { + const { key, prefix, unitless, ignore, token, scope = '' } = config; + const { + cache: { instanceId }, + container, + } = useContext(StyleContext); + const { _tokenKey: tokenKey } = token; + + const stylePath = [...config.path, key, scope, tokenKey]; + + const cache = useGlobalCache>( + CSS_VAR_PREFIX, + stylePath, + () => { + const originToken = fn(); + const [mergedToken, cssVarsStr] = transformToken(originToken, key, { + prefix, + unitless, + ignore, + scope, + }); + const styleId = uniqueHash(stylePath, cssVarsStr); + return [mergedToken, cssVarsStr, styleId, key]; + }, + ([, , styleId]) => { + if (isClientSide) { + removeCSS(styleId, { mark: ATTR_MARK }); + } + }, + ([, cssVarsStr, styleId]) => { + if (!cssVarsStr) { + return; + } + const style = updateCSS(cssVarsStr, styleId, { + mark: ATTR_MARK, + prepend: 'queue', + attachTo: container, + priority: -999, + }); + + (style as any)[CSS_IN_JS_INSTANCE] = instanceId; + + // Used for `useCacheToken` to remove on batch when token removed + style.setAttribute(ATTR_TOKEN, key); + }, + ); + + return cache; +}; + +export const extract: ExtractStyle> = ( + cache, + effectStyles, + options, +) => { + const [, styleStr, styleId, cssVarKey] = cache; + const { plain } = options || {}; + + if (!styleStr) { + return null; + } + + const order = -999; + + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-rc-order': 'prependQueue', + 'data-rc-priority': `${order}`, + }; + + const styleText = toStyleStr( + styleStr, + cssVarKey, + styleId, + sharedAttrs, + plain, + ); + + return [order, styleId, styleText]; +}; + +export default useCSSVarRegister; diff --git a/src/hooks/useCacheToken.tsx b/src/hooks/useCacheToken.tsx index 94a636f3..e4b7fae2 100644 --- a/src/hooks/useCacheToken.tsx +++ b/src/hooks/useCacheToken.tsx @@ -1,8 +1,15 @@ import hash from '@emotion/hash'; +import { updateCSS } from 'rc-util/lib/Dom/dynamicCSS'; import { useContext } from 'react'; -import StyleContext, { ATTR_TOKEN, CSS_IN_JS_INSTANCE } from '../StyleContext'; +import StyleContext, { + ATTR_MARK, + ATTR_TOKEN, + CSS_IN_JS_INSTANCE, +} from '../StyleContext'; import type Theme from '../theme/Theme'; -import { flattenToken, memoResult, token2key } from '../util'; +import { flattenToken, memoResult, token2key, toStyleStr } from '../util'; +import { transformToken } from '../util/css-variables'; +import type { ExtractStyle } from './useGlobalCache'; import useGlobalCache from './useGlobalCache'; const EMPTY_OVERRIDE = {}; @@ -44,6 +51,20 @@ export interface Option { override: object, theme: Theme, ) => DerivativeToken; + + /** + * Transform token to css variables. + */ + cssVar?: { + /** Prefix for css variables */ + prefix?: string; + /** Tokens that should not be appended with unit */ + unitless?: Record; + /** Tokens that should not be transformed to css variables */ + ignore?: Record; + /** Key for current theme. Useful for customizing and should be unique */ + key?: string; + }; } const tokenKeys = new Map(); @@ -110,6 +131,16 @@ export const getComputedToken = < return mergedDerivativeToken; }; +export const TOKEN_PREFIX = 'token'; + +type TokenCacheValue = [ + token: DerivativeToken & { _tokenKey: string; _themeKey: string }, + hashId: string, + realToken: DerivativeToken & { _tokenKey: string }, + cssVarStr: string, + cssVarKey: string, +]; + /** * Cache theme derivative token as global shared one * @param theme Theme entity @@ -124,15 +155,17 @@ export default function useCacheToken< theme: Theme, tokens: Partial[], option: Option = {}, -): [DerivativeToken & { _tokenKey: string }, string] { +): TokenCacheValue { const { cache: { instanceId }, + container, } = useContext(StyleContext); const { salt = '', override = EMPTY_OVERRIDE, formatToken, getComputedToken: compute, + cssVar, } = option; // Basic - We do basic cache here @@ -141,31 +174,110 @@ export default function useCacheToken< const tokenStr = flattenToken(mergedToken); const overrideTokenStr = flattenToken(override); - const cachedToken = useGlobalCache< - [DerivativeToken & { _tokenKey: string }, string] - >( - 'token', - [salt, theme.id, tokenStr, overrideTokenStr], + const cssVarStr = cssVar ? flattenToken(cssVar) : ''; + + const cachedToken = useGlobalCache>( + TOKEN_PREFIX, + [salt, theme.id, tokenStr, overrideTokenStr, cssVarStr], () => { - const mergedDerivativeToken = compute + let mergedDerivativeToken = compute ? compute(mergedToken, override, theme) : getComputedToken(mergedToken, override, theme, formatToken); + // Replace token value with css variables + const actualToken = { ...mergedDerivativeToken }; + let cssVarsStr = ''; + if (!!cssVar) { + [mergedDerivativeToken, cssVarsStr] = transformToken( + mergedDerivativeToken, + cssVar.key!, + { + prefix: cssVar.prefix, + ignore: cssVar.ignore, + unitless: cssVar.unitless, + }, + ); + } + // Optimize for `useStyleRegister` performance const tokenKey = token2key(mergedDerivativeToken, salt); mergedDerivativeToken._tokenKey = tokenKey; - recordCleanToken(tokenKey); + actualToken._tokenKey = token2key(actualToken, salt); + + const themeKey = cssVar?.key ?? tokenKey; + mergedDerivativeToken._themeKey = themeKey; + recordCleanToken(themeKey); - const hashId = `${hashPrefix}-${hash(tokenKey)}`; + const hashId = cssVar + ? `${hashPrefix}-${hash(`${salt}${cssVar.prefix ?? ''}`)}` + : `${hashPrefix}-${hash(tokenKey)}`; mergedDerivativeToken._hashId = hashId; // Not used - return [mergedDerivativeToken, hashId]; + return [ + mergedDerivativeToken, + hashId, + actualToken, + cssVarsStr, + cssVar?.key || '', + ]; }, (cache) => { // Remove token will remove all related style - cleanTokenStyle(cache[0]._tokenKey, instanceId); + cleanTokenStyle(cache[0]._themeKey, instanceId); + }, + ([token, , , cssVarsStr]) => { + if (cssVar && cssVarsStr) { + const style = updateCSS( + cssVarsStr, + hash(`css-variables-${token._themeKey}`), + { + mark: ATTR_MARK, + prepend: 'queue', + attachTo: container, + priority: -999, + }, + ); + + (style as any)[CSS_IN_JS_INSTANCE] = instanceId; + + // Used for `useCacheToken` to remove on batch when token removed + style.setAttribute(ATTR_TOKEN, token._themeKey); + } }, ); return cachedToken; } + +export const extract: ExtractStyle> = ( + cache, + effectStyles, + options, +) => { + const [, , realToken, styleStr, cssVarKey] = cache; + const { plain } = options || {}; + + if (!styleStr) { + return null; + } + + const styleId = realToken._tokenKey; + const order = -999; + + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-rc-order': 'prependQueue', + 'data-rc-priority': `${order}`, + }; + + const styleText = toStyleStr( + styleStr, + cssVarKey, + styleId, + sharedAttrs, + plain, + ); + + return [order, styleId, styleText]; +}; diff --git a/src/hooks/useGlobalCache.tsx b/src/hooks/useGlobalCache.tsx index 1207fd91..ed5bff73 100644 --- a/src/hooks/useGlobalCache.tsx +++ b/src/hooks/useGlobalCache.tsx @@ -5,6 +5,14 @@ import useCompatibleInsertionEffect from './useCompatibleInsertionEffect'; import useEffectCleanupRegister from './useEffectCleanupRegister'; import useHMR from './useHMR'; +export type ExtractStyle = ( + cache: CacheValue, + effectStyles: Record, + options?: { + plain?: boolean; + }, +) => [order: number, styleId: string, style: string] | null; + export default function useGlobalCache( prefix: string, keyPath: KeyType[], @@ -25,7 +33,7 @@ export default function useGlobalCache( const buildCache = (updater?: (data: UpdaterArgs) => UpdaterArgs) => { globalCache.update(fullPath, (prevCache) => { - const [times = 0, cache] = prevCache || []; + const [times = 0, cache] = prevCache || [undefined, undefined]; // HMR should always ignore cache since developer may change it let tmpCache = cache; @@ -88,7 +96,14 @@ export default function useGlobalCache( if (nextCount === 0) { // Always remove styles in useEffect callback - register(() => onCacheRemove?.(cache, false)); + register(() => { + // With polyfill, registered callback will always be called synchronously + // But without polyfill, it will be called in effect clean up, + // And by that time this cache is cleaned up. + if (polyfill || !globalCache.get(fullPath)) { + onCacheRemove?.(cache, false); + } + }); return null; } diff --git a/src/hooks/useStyleRegister/index.tsx b/src/hooks/useStyleRegister.tsx similarity index 64% rename from src/hooks/useStyleRegister/index.tsx rename to src/hooks/useStyleRegister.tsx index d8f0c54a..cfae0dec 100644 --- a/src/hooks/useStyleRegister/index.tsx +++ b/src/hooks/useStyleRegister.tsx @@ -1,34 +1,29 @@ import hash from '@emotion/hash'; import type * as CSS from 'csstype'; -import canUseDom from 'rc-util/lib/Dom/canUseDom'; import { removeCSS, updateCSS } from 'rc-util/lib/Dom/dynamicCSS'; import * as React from 'react'; // @ts-ignore import unitless from '@emotion/unitless'; import { compile, serialize, stringify } from 'stylis'; -import type { Theme, Transformer } from '../..'; -import type Cache from '../../Cache'; -import type Keyframes from '../../Keyframes'; -import type { Linter } from '../../linters'; -import { contentQuotesLinter, hashedAnimationLinter } from '../../linters'; -import type { HashPriority } from '../../StyleContext'; +import type { Theme, Transformer } from '..'; +import type Keyframes from '../Keyframes'; +import type { Linter } from '../linters'; +import { contentQuotesLinter, hashedAnimationLinter } from '../linters'; +import type { HashPriority } from '../StyleContext'; import StyleContext, { ATTR_CACHE_PATH, ATTR_MARK, ATTR_TOKEN, CSS_IN_JS_INSTANCE, -} from '../../StyleContext'; -import { supportLayer } from '../../util'; -import useGlobalCache from '../useGlobalCache'; +} from '../StyleContext'; +import { isClientSide, supportLayer, toStyleStr } from '../util'; import { - ATTR_CACHE_MAP, CSS_FILE_STYLE, existPath, getStyleAndHash, - serialize as serializeCacheMap, -} from './cacheMapUtil'; - -const isClientSide = canUseDom(); +} from '../util/cacheMapUtil'; +import type { ExtractStyle } from './useGlobalCache'; +import useGlobalCache from './useGlobalCache'; const SKIP_CHECK = '_skip_check_'; const MULTI_VALUE = '_multi_value_'; @@ -347,7 +342,7 @@ export const parseStyle = ( // ============================================================================ // == Register == // ============================================================================ -function uniqueHash(path: (string | number)[], styleStr: string) { +export function uniqueHash(path: (string | number)[], styleStr: string) { return hash(`${path.join('%')}${styleStr}`); } @@ -355,6 +350,17 @@ function Empty() { return null; } +export const STYLE_PREFIX = 'style'; + +type StyleCacheValue = [ + styleStr: string, + tokenKey: string, + styleId: string, + effectStyle: Record, + clientOnly: boolean | undefined, + order: number, +]; + /** * Register a style to the global style sheet. */ @@ -398,100 +404,92 @@ export default function useStyleRegister( isMergedClientSide = mock === 'client'; } - const [cachedStyleStr, cachedTokenKey, cachedStyleId] = useGlobalCache< - [ - styleStr: string, - tokenKey: string, - styleId: string, - effectStyle: Record, - clientOnly: boolean | undefined, - order: number, - ] - >( - 'style', - fullPath, - // Create cache if needed - () => { - const cachePath = fullPath.join('|'); - - // Get style from SSR inline style directly - if (existPath(cachePath)) { - const [inlineCacheStyleStr, styleHash] = getStyleAndHash(cachePath); - if (inlineCacheStyleStr) { - return [ - inlineCacheStyleStr, - tokenKey, - styleHash, - {}, - clientOnly, - order, - ]; + const [cachedStyleStr, cachedTokenKey, cachedStyleId] = + useGlobalCache( + STYLE_PREFIX, + fullPath, + // Create cache if needed + () => { + const cachePath = fullPath.join('|'); + + // Get style from SSR inline style directly + if (existPath(cachePath)) { + const [inlineCacheStyleStr, styleHash] = getStyleAndHash(cachePath); + if (inlineCacheStyleStr) { + return [ + inlineCacheStyleStr, + tokenKey, + styleHash, + {}, + clientOnly, + order, + ]; + } } - } - // Generate style - const styleObj = styleFn(); - const [parsedStyle, effectStyle] = parseStyle(styleObj, { - hashId, - hashPriority, - layer, - path: path.join('-'), - transformers, - linters, - }); + // Generate style + const styleObj = styleFn(); + const [parsedStyle, effectStyle] = parseStyle(styleObj, { + hashId, + hashPriority, + layer, + path: path.join('-'), + transformers, + linters, + }); - const styleStr = normalizeStyle(parsedStyle); - const styleId = uniqueHash(fullPath, styleStr); + const styleStr = normalizeStyle(parsedStyle); + const styleId = uniqueHash(fullPath, styleStr); - return [styleStr, tokenKey, styleId, effectStyle, clientOnly, order]; - }, + return [styleStr, tokenKey, styleId, effectStyle, clientOnly, order]; + }, - // Remove cache if no need - ([, , styleId], fromHMR) => { - if ((fromHMR || autoClear) && isClientSide) { - removeCSS(styleId, { mark: ATTR_MARK }); - } - }, - - // Effect: Inject style here - ([styleStr, _, styleId, effectStyle]) => { - if (isMergedClientSide && styleStr !== CSS_FILE_STYLE) { - const mergedCSSConfig: Parameters[2] = { - mark: ATTR_MARK, - prepend: 'queue', - attachTo: container, - priority: order, - }; - - const nonceStr = typeof nonce === 'function' ? nonce() : nonce; - - if (nonceStr) { - mergedCSSConfig.csp = { nonce: nonceStr }; + // Remove cache if no need + ([, , styleId], fromHMR) => { + if ((fromHMR || autoClear) && isClientSide) { + removeCSS(styleId, { mark: ATTR_MARK }); } + }, + + // Effect: Inject style here + ([styleStr, _, styleId, effectStyle]) => { + if (isMergedClientSide && styleStr !== CSS_FILE_STYLE) { + const mergedCSSConfig: Parameters[2] = { + mark: ATTR_MARK, + prepend: 'queue', + attachTo: container, + priority: order, + }; - const style = updateCSS(styleStr, styleId, mergedCSSConfig); + const nonceStr = typeof nonce === 'function' ? nonce() : nonce; - (style as any)[CSS_IN_JS_INSTANCE] = cache.instanceId; + if (nonceStr) { + mergedCSSConfig.csp = { nonce: nonceStr }; + } - // Used for `useCacheToken` to remove on batch when token removed - style.setAttribute(ATTR_TOKEN, tokenKey); + const style = updateCSS(styleStr, styleId, mergedCSSConfig); - // Debug usage. Dev only - if (process.env.NODE_ENV !== 'production') { - style.setAttribute(ATTR_CACHE_PATH, fullPath.join('|')); - } + (style as any)[CSS_IN_JS_INSTANCE] = cache.instanceId; - // Inject client side effect style - Object.keys(effectStyle).forEach((effectKey) => { - updateCSS( - normalizeStyle(effectStyle[effectKey]), - `_effect-${effectKey}`, - mergedCSSConfig, - ); - }); - } - }, - ); + // Used for `useCacheToken` to remove on batch when token removed + style.setAttribute(ATTR_TOKEN, tokenKey); + + // Debug usage. Dev only + if (process.env.NODE_ENV !== 'production') { + style.setAttribute(ATTR_CACHE_PATH, fullPath.join('|')); + } + + // Inject client side effect style + Object.keys(effectStyle).forEach((effectKey) => { + updateCSS( + normalizeStyle(effectStyle[effectKey]), + `_effect-${effectKey}`, + mergedCSSConfig, + ); + }); + } + }, + ); return (node: React.ReactElement) => { let styleNode: React.ReactElement; @@ -519,118 +517,54 @@ export default function useStyleRegister( }; } -// ============================================================================ -// == SSR == -// ============================================================================ -export function extractStyle(cache: Cache, plain = false) { - const matchPrefix = `style%`; - - // prefix with `style` is used for `useStyleRegister` to cache style context - const styleKeys = Array.from(cache.cache.keys()).filter((key) => - key.startsWith(matchPrefix), - ); - - // Common effect styles like animation - const effectStyles: Record = {}; - - // Mapping of cachePath to style hash - const cachePathMap: Record = {}; - - let styleText = ''; - - function toStyleStr( - style: string, - tokenKey?: string, - styleId?: string, - customizeAttrs: Record = {}, - ) { - const attrs: Record = { - ...customizeAttrs, - [ATTR_TOKEN]: tokenKey, - [ATTR_MARK]: styleId, - }; - - const attrStr = Object.keys(attrs) - .map((attr) => { - const val = attrs[attr]; - return val ? `${attr}="${val}"` : null; - }) - .filter((v) => v) - .join(' '); - - return plain ? style : ``; +export const extract: ExtractStyle = ( + cache, + effectStyles, + options, +) => { + const [ + styleStr, + tokenKey, + styleId, + effectStyle, + clientOnly, + order, + ]: StyleCacheValue = cache; + const { plain } = options || {}; + + // Skip client only style + if (clientOnly) { + return null; } - // ====================== Fill Style ====================== - type OrderStyle = [order: number, style: string]; - - const orderStyles: OrderStyle[] = styleKeys - .map((key) => { - const cachePath = key.slice(matchPrefix.length).replace(/%/g, '|'); - - const [styleStr, tokenKey, styleId, effectStyle, clientOnly, order]: [ - string, - string, - string, - Record, - boolean, - number, - ] = cache.cache.get(key)![1]; - - // Skip client only style - if (clientOnly) { - return null! as OrderStyle; - } - - // ====================== Style ====================== - // Used for rc-util - const sharedAttrs = { - 'data-rc-order': 'prependQueue', - 'data-rc-priority': `${order}`, - }; + let keyStyleText = styleStr; - let keyStyleText = toStyleStr(styleStr, tokenKey, styleId, sharedAttrs); - - // Save cache path with hash mapping - cachePathMap[cachePath] = styleId; + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-rc-order': 'prependQueue', + 'data-rc-priority': `${order}`, + }; - // =============== Create effect style =============== - if (effectStyle) { - Object.keys(effectStyle).forEach((effectKey) => { - // Effect style can be reused - if (!effectStyles[effectKey]) { - effectStyles[effectKey] = true; - keyStyleText += toStyleStr( - normalizeStyle(effectStyle[effectKey]), - tokenKey, - `_effect-${effectKey}`, - sharedAttrs, - ); - } - }); + keyStyleText = toStyleStr(styleStr, tokenKey, styleId, sharedAttrs, plain); + + // =============== Create effect style =============== + if (effectStyle) { + Object.keys(effectStyle).forEach((effectKey) => { + // Effect style can be reused + if (!effectStyles[effectKey]) { + effectStyles[effectKey] = true; + const effectStyleStr = normalizeStyle(effectStyle[effectKey]); + keyStyleText += toStyleStr( + effectStyleStr, + tokenKey, + `_effect-${effectKey}`, + sharedAttrs, + plain, + ); } - - const ret: OrderStyle = [order, keyStyleText]; - - return ret; - }) - .filter((o) => o); - - orderStyles - .sort((o1, o2) => o1[0] - o2[0]) - .forEach(([, style]) => { - styleText += style; }); + } - // ==================== Fill Cache Path ==================== - styleText += toStyleStr( - `.${ATTR_CACHE_MAP}{content:"${serializeCacheMap(cachePathMap)}";}`, - undefined, - undefined, - { - [ATTR_CACHE_MAP]: ATTR_CACHE_MAP, - }, - ); - - return styleText; -} + return [order, styleId, keyStyleText]; +}; diff --git a/src/index.ts b/src/index.ts index dbf083b4..5dc10a65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,31 @@ +import extractStyle from './extractStyle'; import useCacheToken, { getComputedToken } from './hooks/useCacheToken'; +import useCSSVarRegister from './hooks/useCSSVarRegister'; import type { CSSInterpolation, CSSObject } from './hooks/useStyleRegister'; -import useStyleRegister, { extractStyle } from './hooks/useStyleRegister'; +import useStyleRegister from './hooks/useStyleRegister'; import Keyframes from './Keyframes'; import type { Linter } from './linters'; import { legacyNotSelectorLinter, logicalPropertiesLinter, + NaNLinter, parentSelectorLinter, } from './linters'; +import type { StyleProviderProps } from './StyleContext'; import { createCache, StyleProvider } from './StyleContext'; -import type {StyleProviderProps} from './StyleContext'; import type { DerivativeFunc, TokenType } from './theme'; import { createTheme, Theme } from './theme'; import type { Transformer } from './transformers/interface'; import legacyLogicalPropertiesTransformer from './transformers/legacyLogicalProperties'; import px2remTransformer from './transformers/px2rem'; -import { supportLogicProps, supportWhere } from './util'; +import { supportLogicProps, supportWhere, unit } from './util'; +import { token2CSSVar } from './util/css-variables'; export { Theme, createTheme, useStyleRegister, + useCSSVarRegister, useCacheToken, createCache, StyleProvider, @@ -36,6 +41,11 @@ export { logicalPropertiesLinter, legacyNotSelectorLinter, parentSelectorLinter, + NaNLinter, + + // util + token2CSSVar, + unit, }; export type { TokenType, diff --git a/src/linters/NaNLinter.ts b/src/linters/NaNLinter.ts new file mode 100644 index 00000000..f9c9dcd9 --- /dev/null +++ b/src/linters/NaNLinter.ts @@ -0,0 +1,13 @@ +import type { Linter } from './interface'; +import { lintWarning } from './utils'; + +const linter: Linter = (key, value, info) => { + if ( + (typeof value === 'string' && /NaN/g.test(value)) || + Number.isNaN(value) + ) { + lintWarning(`Unexpected 'NaN' in property '${key}: ${value}'.`, info); + } +}; + +export default linter; diff --git a/src/linters/index.ts b/src/linters/index.ts index ae7d8cc9..2e31efe5 100644 --- a/src/linters/index.ts +++ b/src/linters/index.ts @@ -3,4 +3,5 @@ export { default as hashedAnimationLinter } from './hashedAnimationLinter'; export type { Linter } from './interface'; export { default as legacyNotSelectorLinter } from './legacyNotSelectorLinter'; export { default as logicalPropertiesLinter } from './logicalPropertiesLinter'; +export { default as NaNLinter } from './NaNLinter'; export { default as parentSelectorLinter } from './parentSelectorLinter'; diff --git a/src/hooks/useStyleRegister/cacheMapUtil.ts b/src/util/cacheMapUtil.ts similarity index 97% rename from src/hooks/useStyleRegister/cacheMapUtil.ts rename to src/util/cacheMapUtil.ts index 53760038..2500dd16 100644 --- a/src/hooks/useStyleRegister/cacheMapUtil.ts +++ b/src/util/cacheMapUtil.ts @@ -1,5 +1,5 @@ import canUseDom from 'rc-util/lib/Dom/canUseDom'; -import { ATTR_MARK } from '../../StyleContext'; +import { ATTR_MARK } from '../StyleContext'; export const ATTR_CACHE_MAP = 'data-ant-cssinjs-cache-path'; diff --git a/src/util/css-variables.ts b/src/util/css-variables.ts new file mode 100644 index 00000000..580c6473 --- /dev/null +++ b/src/util/css-variables.ts @@ -0,0 +1,63 @@ +export const token2CSSVar = (token: string, prefix = '') => { + return `--${prefix ? `${prefix}-` : ''}${token}` + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1-$2') + .replace(/([a-z])([A-Z0-9])/g, '$1-$2') + .toLowerCase(); +}; + +export const serializeCSSVar = >( + cssVars: T, + hashId: string, + options?: { + scope?: string; + }, +) => { + if (!Object.keys(cssVars).length) { + return ''; + } + return `.${hashId}${ + options?.scope ? `.${options.scope}` : '' + }{${Object.entries(cssVars) + .map(([key, value]) => `${key}:${value};`) + .join('')}}`; +}; + +export type TokenWithCSSVar = { + [key in keyof T]?: string; +}; + +export const transformToken = < + V, + T extends Record = Record, +>( + token: T, + themeKey: string, + config?: { + prefix?: string; + ignore?: { + [key in keyof T]?: boolean; + }; + unitless?: { + [key in keyof T]?: boolean; + }; + scope?: string; + }, +): [TokenWithCSSVar, string] => { + const cssVars: Record = {}; + const result: TokenWithCSSVar = {}; + Object.entries(token).forEach(([key, value]) => { + if ( + (typeof value === 'string' || typeof value === 'number') && + !config?.ignore?.[key] + ) { + const cssVar = token2CSSVar(key, config?.prefix); + cssVars[cssVar] = + typeof value === 'number' && !config?.unitless?.[key] + ? `${value}px` + : String(value); + result[key as keyof T] = `var(${cssVar})`; + } + }); + return [result, serializeCSSVar(cssVars, themeKey, { scope: config?.scope })]; +}; diff --git a/src/util.ts b/src/util/index.ts similarity index 81% rename from src/util.ts rename to src/util/index.ts index b5c7c553..57932b83 100644 --- a/src/util.ts +++ b/src/util/index.ts @@ -1,7 +1,8 @@ import hash from '@emotion/hash'; import canUseDom from 'rc-util/lib/Dom/canUseDom'; import { removeCSS, updateCSS } from 'rc-util/lib/Dom/dynamicCSS'; -import { Theme } from './theme'; +import { ATTR_MARK, ATTR_TOKEN } from '../StyleContext'; +import { Theme } from '../theme'; // Create a cache for memo concat type NestWeakMap = WeakMap | T>; @@ -146,3 +147,39 @@ export function supportLogicProps(): boolean { return canLogic!; } + +export const isClientSide = canUseDom(); + +export function unit(num: string | number) { + if (typeof num === 'number') { + return `${num}px`; + } + return num; +} + +export function toStyleStr( + style: string, + tokenKey?: string, + styleId?: string, + customizeAttrs: Record = {}, + plain = false, +) { + if (plain) { + return style; + } + const attrs: Record = { + ...customizeAttrs, + [ATTR_TOKEN]: tokenKey, + [ATTR_MARK]: styleId, + }; + + const attrStr = Object.keys(attrs) + .map((attr) => { + const val = attrs[attr]; + return val ? `${attr}="${val}"` : null; + }) + .filter((v) => v) + .join(' '); + + return ``; +} diff --git a/tests/__snapshots__/css-variables.spec.tsx.snap b/tests/__snapshots__/css-variables.spec.tsx.snap new file mode 100644 index 00000000..f1a7fc8e --- /dev/null +++ b/tests/__snapshots__/css-variables.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CSS Variables > support ssr 1`] = `""`; diff --git a/tests/css-variables.spec.tsx b/tests/css-variables.spec.tsx new file mode 100644 index 00000000..ff7d770b --- /dev/null +++ b/tests/css-variables.spec.tsx @@ -0,0 +1,453 @@ +import { TinyColor } from '@ctrl/tinycolor'; +import { render } from '@testing-library/react'; +import classNames from 'classnames'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; +import { expect } from 'vitest'; +import { + createCache, + createTheme, + extractStyle, + StyleProvider, + unit, + useCacheToken, + useCSSVarRegister, + useStyleRegister, +} from '../src'; + +export interface DesignToken { + primaryColor: string; + textColor: string; + + borderRadius: number; + borderColor: string; + borderWidth: number; + + lineHeight: number; + lineHeightBase: number; +} + +export interface DerivativeToken extends DesignToken { + primaryColorDisabled: string; +} + +const defaultDesignToken: DesignToken = { + primaryColor: '#1890ff', + textColor: '#333333', + + borderRadius: 2, + borderColor: 'black', + borderWidth: 1, + + lineHeight: 1.5, + lineHeightBase: 1.5, +}; + +// 模拟推导过程 +function derivative(designToken: DesignToken): DerivativeToken { + return { + ...designToken, + primaryColorDisabled: new TinyColor(designToken.primaryColor) + .setAlpha(0.5) + .toString(), + }; +} + +const theme = createTheme(derivative); + +type DesignTokenProviderProps = { + token?: Partial; + hashed?: string | boolean; + cssVar?: { + key: string; + prefix?: string; + }; +}; + +const DesignTokenContext = React.createContext({ + token: defaultDesignToken, + hashed: true, +}); + +const DesignTokenProvider: React.FC< + PropsWithChildren<{ theme: DesignTokenProviderProps }> +> = ({ theme: customTheme, children }) => { + const parentContext = React.useContext(DesignTokenContext); + + const mergedCtx = React.useMemo(() => { + return { + token: { + ...parentContext.token, + ...customTheme.token, + }, + hashed: customTheme.hashed ?? parentContext.hashed, + cssVar: customTheme.cssVar, + }; + }, [ + parentContext.token, + parentContext.hashed, + customTheme.token, + customTheme.hashed, + customTheme.cssVar, + ]); + + return ( + + {children} + + ); +}; + +function useToken(): [DerivativeToken, string, string, DerivativeToken] { + const { + token: rootDesignToken = {}, + hashed, + cssVar, + } = React.useContext(DesignTokenContext); + + const [token, hashId, realToken] = useCacheToken< + DerivativeToken, + DesignToken + >(theme, [defaultDesignToken, rootDesignToken], { + salt: typeof hashed === 'string' ? hashed : '', + cssVar: cssVar && { + prefix: cssVar.prefix ?? 'rc', + key: cssVar.key, + unitless: { + lineHeight: true, + }, + ignore: { + lineHeightBase: true, + }, + }, + }); + return [token, hashed ? hashId : '', cssVar?.key || '', realToken]; +} + +const useStyle = () => { + const [token, hashId, cssVarKey, realToken] = useToken(); + + const getComponentToken = () => ({ boxColor: '#5c21ff' }); + + const [cssVarToken] = useCSSVarRegister( + { + path: ['Box'], + key: cssVarKey, + token: realToken, + prefix: 'rc-box', + unitless: { + lineHeight: true, + }, + ignore: { + lineHeightBase: true, + }, + scope: 'box', + }, + cssVarKey ? getComponentToken : () => ({}), + ); + + useStyleRegister( + { + theme, + token, + hashId, + path: ['Box'], + }, + () => { + // @ts-ignore + const mergedToken: DerivativeToken & { boxColor: string } = { + ...token, + ...(cssVarKey ? cssVarToken : getComponentToken()), + }; + + return { + '.box': { + lineHeight: mergedToken.lineHeight, + border: `${unit(mergedToken.borderWidth)} solid ${ + mergedToken.borderColor + }`, + color: mergedToken.boxColor, + backgroundColor: mergedToken.primaryColor, + }, + }; + }, + ); + + return `${hashId}${cssVarKey ? ` ${cssVarKey}` : ''}`; +}; + +const Box = (props: { className?: string }) => { + const cls = useStyle(); + + return
; +}; + +describe('CSS Variables', () => { + beforeEach(() => { + const styles = Array.from(document.head.querySelectorAll('style')); + styles.forEach((style) => { + style.parentNode?.removeChild(style); + }); + }); + + it('should work with cssVar', () => { + const { container } = render( + + + , + ); + + const styles = Array.from(document.head.querySelectorAll('style')); + const box = container.querySelector('.target')!; + + expect(styles.length).toBe(3); + expect(styles[0].textContent).toContain('.apple{'); + expect(styles[0].textContent).toContain('--rc-line-height:1.5;'); + expect(styles[0].textContent).not.toContain('--rc-line-height-base:1.5;'); + expect(styles[1].textContent).toContain('--rc-box-box-color:#5c21ff'); + expect(styles[1].textContent).toContain('.apple.box{'); + expect(styles[2].textContent).toContain( + 'line-height:var(--rc-line-height);', + ); + expect(box).toHaveClass('apple'); + expect(box).toHaveStyle({ + '--rc-line-height': '1.5', + lineHeight: 'var(--rc-line-height)', + }); + }); + + it('could mix with non-css-var', () => { + const { container } = render( + <> + + + + + + + + + + + , + ); + + const styles = Array.from(document.head.querySelectorAll('style')); + expect(styles).toHaveLength(7); + + const nonCssVarBox = container.querySelector('.non-css-var')!; + expect(nonCssVarBox).toHaveStyle({ + lineHeight: '1.5', + border: '1px solid black', + backgroundColor: '#1890ff', + color: '#5c21ff', + }); + + const cssVarBox = container.querySelector('.css-var')!; + expect(cssVarBox).toHaveStyle({ + '--rc-line-height': '1.5', + '--rc-border-width': '1px', + '--rc-border-color': 'black', + '--rc-primary-color': '#1677ff', + '--rc-box-box-color': '#5c21ff', + lineHeight: 'var(--rc-line-height)', + border: 'var(--rc-border-width) solid var(--rc-border-color)', + backgroundColor: 'var(--rc-primary-color)', + color: 'var(--rc-box-box-color)', + }); + + const cssVarBox2 = container.querySelector('.css-var-2')!; + expect(cssVarBox2).toHaveClass('banana'); + expect(cssVarBox2).not.toHaveClass('apple'); + expect(cssVarBox2).toHaveStyle({ + '--rc-line-height': '1.5', + '--rc-border-width': '2px', + '--rc-border-color': 'black', + '--rc-primary-color': '#1677ff', + '--rc-box-box-color': '#5c21ff', + lineHeight: 'var(--rc-line-height)', + border: 'var(--rc-border-width) solid var(--rc-border-color)', + backgroundColor: 'var(--rc-primary-color)', + color: 'var(--rc-box-box-color)', + }); + + const nonCssVarBox2 = container.querySelector('.non-css-var-2')!; + expect(nonCssVarBox2).not.toHaveClass('banana'); + expect(nonCssVarBox2).not.toHaveClass('apple'); + expect(nonCssVarBox2).toHaveStyle({ + lineHeight: '1.5', + border: '3px solid black', + backgroundColor: '#1677ff', + color: '#5c21ff', + }); + }); + + it('dynamic', () => { + const Demo = (props: { token?: Partial }) => ( + + + + ); + + const { container, rerender } = render(); + + let styles = Array.from(document.head.querySelectorAll('style')); + const box = container.querySelector('.target')!; + + expect(styles.length).toBe(3); + expect(box).toHaveClass('apple'); + expect(box).toHaveStyle({ + '--rc-line-height': '1.5', + lineHeight: 'var(--rc-line-height)', + }); + + rerender(); + + styles = Array.from(document.head.querySelectorAll('style')); + + expect(styles.length).toBe(3); + expect(box).toHaveClass('apple'); + expect(box).toHaveStyle({ + '--rc-line-height': '2', + lineHeight: 'var(--rc-line-height)', + }); + }); + + it('could autoClear', () => { + const { rerender } = render( + + + + + , + ); + + let styles = Array.from(document.head.querySelectorAll('style')); + expect(styles.length).toBe(3); + + rerender( + + +
+ + , + ); + + styles = Array.from(document.head.querySelectorAll('style')); + expect(styles.length).toBe(1); + }); + + it('support ssr', () => { + const cache = createCache(); + render( + + + + + , + ); + + expect(extractStyle(cache)).toMatchSnapshot(); + }); + + it('css var prefix should regenerate component style', () => { + const { rerender } = render( + + + , + ); + + let styles = Array.from(document.head.querySelectorAll('style')); + expect(styles.length).toBe(3); + expect( + styles.some((style) => style.textContent?.includes('var(--app-')), + ).toBe(true); + expect( + styles.some((style) => style.textContent?.includes('var(--bank-')), + ).toBe(false); + + rerender( + + + , + ); + + styles = Array.from(document.head.querySelectorAll('style')); + expect(styles.length).toBe(4); + expect( + styles.some((style) => style.textContent?.includes('var(--app-')), + ).toBe(true); + expect( + styles.some((style) => style.textContent?.includes('var(--bank-')), + ).toBe(true); + }); +}); diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index 54c85b57..989a8fdf 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -10,6 +10,7 @@ import { StyleProvider, Theme, useCacheToken, + useCSSVarRegister, useStyleRegister, } from '../src'; import { ATTR_MARK, ATTR_TOKEN, CSS_IN_JS_INSTANCE } from '../src/StyleContext'; @@ -713,4 +714,67 @@ describe('csssinjs', () => { }); }); }); + + describe('should not cleanup style when unmount and mount', () => { + const test = ( + wrapper: (node: ReactElement) => ReactElement = (node) => node, + ) => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const Demo = ({ + myToken, + children, + }: { + myToken?: string; + children?: ReactNode; + }) => { + const [token, hashId] = useCacheToken( + theme, + [{ primaryColor: myToken }], + { + salt: 'test', + }, + ); + + useCSSVarRegister( + { + key: 'color', + path: ['cssinjs-cleanup-style-when-remount'], + token, + }, + () => ({ + token: token.primaryColor, + }), + ); + + return
{children}
; + }; + + const { rerender } = render(wrapper()); + const styles = Array.from(document.head.querySelectorAll('style')); + expect(styles).toHaveLength(1); + + rerender( + wrapper( +
+ +
, + ), + ); + const styles2 = Array.from(document.head.querySelectorAll('style')); + expect(styles2).toHaveLength(1); + + spy.mockRestore(); + }; + + it('normal', () => { + test(); + }); + + it('strict mode', () => { + test((node) => { + return {node}; + }); + }); + }); }); diff --git a/tests/legacy.spec.tsx b/tests/legacy.spec.tsx index 2ec36bfe..2e7bca9c 100644 --- a/tests/legacy.spec.tsx +++ b/tests/legacy.spec.tsx @@ -1,9 +1,16 @@ import { render } from '@testing-library/react'; +import classNames from 'classnames'; +import type { ReactElement, ReactNode } from 'react'; import * as React from 'react'; -import { useLayoutEffect } from 'react'; +import { StrictMode, useLayoutEffect } from 'react'; import { expect } from 'vitest'; import type { CSSInterpolation } from '../src'; -import { Theme, useCacheToken, useStyleRegister } from '../src'; +import { + Theme, + useCacheToken, + useCSSVarRegister, + useStyleRegister, +} from '../src'; interface DesignToken { primaryColor: string; @@ -157,4 +164,67 @@ describe('legacy React version', () => { rerender(); expect(document.head.querySelectorAll('style')).toHaveLength(2); }); + + describe('should not cleanup style when unmount and mount', () => { + const test = ( + wrapper: (node: ReactElement) => ReactElement = (node) => node, + ) => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const Demo = ({ + myToken, + children, + }: { + myToken?: string; + children?: ReactNode; + }) => { + const [token, hashId] = useCacheToken( + theme, + [{ primaryColor: myToken }], + { + salt: 'test', + }, + ); + + useCSSVarRegister( + { + key: 'color', + path: ['cssinjs-cleanup-style-when-remount'], + token, + }, + () => ({ + token: token.primaryColor, + }), + ); + + return
{children}
; + }; + + const { rerender } = render(wrapper()); + const styles = Array.from(document.head.querySelectorAll('style')); + expect(styles).toHaveLength(1); + + rerender( + wrapper( +
+ +
, + ), + ); + const styles2 = Array.from(document.head.querySelectorAll('style')); + expect(styles2).toHaveLength(1); + + spy.mockRestore(); + }; + + it('normal', () => { + test(); + }); + + it('strict mode', () => { + test((node) => { + return {node}; + }); + }); + }); }); diff --git a/tests/server.spec.tsx b/tests/server.spec.tsx index e5cded8f..d96e4544 100644 --- a/tests/server.spec.tsx +++ b/tests/server.spec.tsx @@ -12,9 +12,9 @@ import { useCacheToken, useStyleRegister, } from '../src'; -import * as cacheMapUtil from '../src/hooks/useStyleRegister/cacheMapUtil'; -import { reset } from '../src/hooks/useStyleRegister/cacheMapUtil'; import { ATTR_MARK, CSS_IN_JS_INSTANCE } from '../src/StyleContext'; +import * as cacheMapUtil from '../src/util/cacheMapUtil'; +import { reset } from '../src/util/cacheMapUtil'; interface DesignToken { primaryColor: string; diff --git a/tests/theme.spec.tsx b/tests/theme.spec.tsx index a22d0ea4..7959488e 100644 --- a/tests/theme.spec.tsx +++ b/tests/theme.spec.tsx @@ -189,7 +189,7 @@ describe('Theme', () => { }); it('should warn if empty array', () => { - const errSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(errSpy).toHaveBeenCalledTimes(0); createTheme([]); expect(errSpy).toHaveBeenCalledTimes(1);