import { isAndroid, isClient, isIos, isWeb, useIsomorphicLayoutEffect, } from '@tamagui/constants' import { StyleObjectIdentifier, StyleObjectProperty, StyleObjectPseudo, StyleObjectRules, nonAnimatableStyleProps, stylePropsText, stylePropsTransform, tokenCategories, validPseudoKeys, validStyles as validStylesView, } from '@tamagui/helpers' import React from 'react' import { getConfig, getFont, getSetting } from '../config' import { isDevTools } from '../constants/isDevTools' import { getMediaImportanceIfMoreImportant, getMediaKey, getMediaKeyImportance, mediaKeyMatch, } from '../hooks/useMedia' import { mediaState as globalMediaState, mediaQueryConfig } from './mediaState' import type { AllGroupContexts, AnimationDriver, ClassNamesObject, ComponentContextI, DebugProp, GetStyleResult, GetStyleState, PseudoStyles, RulesToInsert, SpaceTokens, SplitStyleProps, StaticConfig, StyleObject, TamaguiComponentState, TamaguiInternalConfig, TextStyle, ThemeParsed, ViewStyleWithPseudos, } from '../types' import { createMediaStyle } from './createMediaStyle' import { fixStyles } from './expandStyles' import { getCSSStylesAtomic, getStyleAtomic, styleToCSS } from './getCSSStylesAtomic' import { getDefaultProps } from './getDefaultProps' import { extractValueFromDynamic, getDynamicVal, getOppositeScheme, isColorStyleKey, } from './getDynamicVal' import { getGroupPropParts } from './getGroupPropParts' import { insertStyleRules, shouldInsertStyleRules, updateRules } from './insertStyleRule' import { isActivePlatform, getPlatformSpecificityBump } from './isActivePlatform' import { isActiveTheme } from './isActiveTheme' import { log } from './log' import { normalizeValueWithProperty } from './normalizeValueWithProperty' import { propMapper } from './propMapper' import { type PseudoDescriptorKey, pseudoDescriptors, pseudoPriorities, defaultMediaImportance, } from './pseudoDescriptors' import { skipProps } from './skipProps' import { sortString } from './sortString' import { transformsToString } from './transformsToString' export type SplitStyles = ReturnType export type SplitStyleResult = ReturnType let conf: TamaguiInternalConfig // WeakMap to track original token values for style objects // Used to preserve '$8' style tokens instead of resolved 'var(--t-space-8)' // for context prop propagation to children (issues #3670, #3676) export const styleOriginalValues = new WeakMap>() type StyleSplitter = ( props: { [key: string]: any }, staticConfig: StaticConfig, theme: ThemeParsed, themeName: string, componentState: TamaguiComponentState, styleProps: SplitStyleProps, parentSplitStyles?: GetStyleResult | null, context?: ComponentContextI, groupContext?: AllGroupContexts | null, // web-only elementType?: string, startedUnhydrated?: boolean, debug?: DebugProp, // resolved animation driver (respects animatedBy prop) animationDriver?: AnimationDriver | null ) => null | GetStyleResult export const PROP_SPLIT = '-' // Normalize group keys like $group-press to $group-true-press when the group name // doesn't exist in context (defaults to the unnamed 'true' group) function normalizeGroupKey( key: string, groupContext: AllGroupContexts | null | undefined ): string { const parts = key.split('-') const plen = parts.length if ( // check if its actually a simple group selector to avoid breaking selectors plen === 2 || (plen === 3 && pseudoPriorities[parts[parts.length - 1]]) ) { const name = parts[1] if (name !== 'true' && groupContext && !groupContext[name]) { return key.replace('$group-', '$group-true-') } } return key } // if you need and easier way to test performance, you can do something like this // add this early return somewhere in this file and you can see roughly where it slows down: // return { // space, // hasMedia, // fontFamily: styleState.fontFamily, // viewProps: { // children: props.children, // }, // style: { // borderColor: props.borderColor, // borderWidth: props.borderWidth, // padding: props.padding, // }, // pseudos, // classNames, // rulesToInsert, // dynamicThemeAccess, // } function isValidStyleKey( key: string, validStyles: Record, accept?: Record ) { return key in validStyles ? true : accept && key in accept } export const getSplitStyles: StyleSplitter = ( props, staticConfig, theme, themeName, componentState, styleProps, parentSplitStyles, componentContext, groupContext, elementType, startedUnhydrated, debug, animationDriver ) => { conf = conf || getConfig() // use passed animationDriver or fall back to context/config const driver = animationDriver || componentContext?.animationDriver || (conf.animations as AnimationDriver) if (props.passThrough) { return null } // a bit icky, we need no normalize but not fully if ( isWeb && styleProps.isAnimated && driver?.isReactNative && !styleProps.noNormalize ) { styleProps.noNormalize = 'values' } const { shorthands } = conf const { isHOC, isText, isInput, variants, isReactNative, inlineProps, inlineWhenUnflattened, parentStaticConfig, acceptsClassName, } = staticConfig const viewProps: GetStyleResult['viewProps'] = {} const mediaState = styleProps.mediaState || globalMediaState const shouldDoClasses = acceptsClassName && isWeb && !styleProps.noClass const rulesToInsert: RulesToInsert = process.env.TAMAGUI_TARGET === 'native' ? (undefined as any) : {} const classNames: ClassNamesObject = {} let space: SpaceTokens | null = props.space let pseudos: PseudoStyles | null = null let hasMedia: boolean | Set = false let dynamicThemeAccess: boolean | undefined let pseudoGroups: Set | undefined let mediaGroups: Set | undefined let className = (props.className as string) || '' // existing classNames let mediaStylesSeen = 0 const validStyles = staticConfig.validStyles || (staticConfig.isText || staticConfig.isInput ? stylePropsText : validStylesView) if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`split-styles-setup` } /** * Not the biggest fan of creating an object but it is a nice API */ const styleState: GetStyleState = { classNames, conf, props, styleProps, componentState, staticConfig, style: null, theme, usedKeys: {}, viewProps, context: componentContext, debug, // resolved animation driver (respects animatedBy prop) animationDriver: driver, } // only used by compiler if (process.env.IS_STATIC === 'is_static') { const { fallbackProps } = styleProps if (fallbackProps) { styleState.props = new Proxy(props, { get(_, key, val) { if (!Reflect.has(props, key)) { return Reflect.get(fallbackProps, key) } return Reflect.get(props, key) }, }) } } if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`style-state` } if (process.env.NODE_ENV === 'development' && debug === 'verbose' && isClient) { if (isDevTools) { console.groupCollapsed('🔹 getSplitStyles 👇') log({ props, staticConfig, shouldDoClasses, styleProps, rulesToInsert, componentState, styleState, theme: { ...theme }, }) } } const { asChild } = props const { accept } = staticConfig const { noSkip, disableExpandShorthands, noExpand, styledContext } = styleProps const { webContainerType } = conf.settings const parentVariants = parentStaticConfig?.variants for (const keyOg in props) { let keyInit = keyOg let valInit = props[keyInit] if (keyInit === 'children') { viewProps[keyInit] = valInit continue } if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`before-prop-${keyInit}` } if (process.env.NODE_ENV === 'test' && keyInit === 'jestAnimatedStyle') { continue } // for custom accept sub-styles if (accept) { const accepted = accept[keyInit] if ( (accepted === 'style' || accepted === 'textStyle') && valInit && typeof valInit === 'object' ) { viewProps[keyInit] = getSubStyle(styleState, keyInit, valInit, styleProps.noClass) continue } } // normalize shorthands up front if (!disableExpandShorthands) { if (keyInit in shorthands) { keyInit = shorthands[keyInit] } } if (keyInit === 'className') continue // handled above first // when asChild, skip default props - they shouldn't be passed down to children if (asChild) { const defaults = getDefaultProps(staticConfig) if (defaults) { // check both original key and expanded key (after shorthand expansion) const defaultVal = defaults[keyOg] ?? defaults[keyInit] if (defaultVal !== undefined && valInit === defaultVal) { continue } } } // keyInit === 'style' is handled in skipProps if (keyInit in skipProps && !noSkip && !isHOC) { if (keyInit === 'group') { if (process.env.TAMAGUI_TARGET === 'web') { // add container style const identifier = `t_group_${valInit}` const containerType = webContainerType || 'inline-size' const containerCSS = [ 'container', undefined, identifier, undefined, [ `.${identifier} { container-name: ${valInit}; container-type: ${containerType}; }`, ], ] satisfies StyleObject addStyleToInsertRules(rulesToInsert, containerCSS) } } // transition prop is skipped when it's a named animation (e.g. 'quick') // but raw CSS values (from $platform-web) should pass through as style if ( keyInit === 'transition' && typeof valInit === 'string' && !driver?.animations?.[valInit] ) { // not a known animation name, treat as raw CSS } else { continue } } let isValidStyleKeyInit = isValidStyleKey(keyInit, validStyles, accept) // this is all for partially optimized (not flattened)... maybe worth removing? if (process.env.TAMAGUI_TARGET === 'web') { // react-native-web ignores data-* attributes, fixes passing them to animated views if (staticConfig.isReactNative && keyInit.startsWith('data-')) { keyInit = keyInit.replace('data-', '') viewProps['dataSet'] ||= {} viewProps['dataSet'][keyInit] = valInit continue } } if (process.env.TAMAGUI_TARGET === 'native') { if (!isValidStyleKeyInit) { if (!isAndroid) { // only works in android if (keyInit === 'elevationAndroid') continue } // map userSelect to native prop if (keyInit === 'userSelect') { keyInit = 'selectable' valInit = valInit !== 'none' } else if (keyInit.startsWith('data-')) { continue } } } if (process.env.TAMAGUI_TARGET === 'web') { if (!noExpand) { /** * Copying in the accessibility/prop handling from react-native-web here * Keeps it in a single loop, avoids dup de-structuring to avoid bundle size */ if (keyInit === 'disabled' && valInit === true) { viewProps['aria-disabled'] = true // Enhance with native semantics if ( elementType === 'button' || elementType === 'form' || elementType === 'input' || elementType === 'select' || elementType === 'textarea' ) { viewProps.disabled = true } if (!variants?.disabled) { continue } } if (keyInit === 'testID') { if (isReactNative) { viewProps.testID = valInit } else { viewProps['data-testid'] = valInit // also keep testID when using RN animation driver (Animated.View // from react-native-web only forwards testID, not data-testid) if (styleProps.isAnimated && driver?.isReactNative) { viewProps.testID = valInit } } continue } if (keyInit === 'id') { viewProps.id = valInit continue } } } /** * There's (some) reason to this madness: we want to allow returning media/pseudo from variants * Say you have a variant hoverable: { true: { hoverStyle: {} } } * We run propMapper first to expand variant, then we run the inner loop and look again * for if there's a pseudo/media returned from it. */ let isVariant = !isValidStyleKeyInit && variants && keyInit in variants const isStyleLikeKey = isValidStyleKeyInit || isVariant let isPseudo = keyInit in validPseudoKeys let isMedia = !isStyleLikeKey && !isPseudo ? getMediaKey(keyInit) : false let isMediaOrPseudo = Boolean(isMedia || isPseudo) if (isMediaOrPseudo && isMedia === 'group') { keyInit = normalizeGroupKey(keyInit, groupContext) } const isStyleProp = isValidStyleKeyInit || isMediaOrPseudo || (isVariant && !noExpand) if (isStyleProp && (asChild === 'except-style' || asChild === 'except-style-web')) { continue } const shouldPassProp = (!isStyleProp && isHOC) || // is in parent variants (isHOC && parentVariants && keyInit in parentVariants) || inlineProps?.has(keyInit) const parentVariant = parentVariants?.[keyInit] const isHOCShouldPassThrough = Boolean( isHOC && (isValidStyleKeyInit || isMediaOrPseudo || parentVariant || keyInit in skipProps) ) const shouldPassThrough = shouldPassProp || isHOCShouldPassThrough if (process.env.NODE_ENV === 'development' && debug === 'verbose') { // console.groupEnd() // react native was not nesting right console.groupCollapsed( ` 🔑 ${keyOg}${ keyInit !== keyOg ? ` (shorthand for ${keyInit})` : '' } ${shouldPassThrough ? '(pass)' : ''}` ) log({ isVariant, valInit, shouldPassProp }) if (isClient) { log({ variants, variant: variants?.[keyInit], isVariant, isHOCShouldPassThrough, usedKeys: { ...styleState.usedKeys }, parentStaticConfig, }) } } if (shouldPassThrough) { // // TODO bring this back but probably improve it? // if (isPseudo) { // // this is a lot... but we need to track sub-keys so we don't override them in future things that aren't passed down // // like our own variants that aren't in parent // const pseudoStyleObject = getSubStyle( // styleState, // keyInit, // valInit, // fontFamily, // true, // state.noClass // ) // const descriptor = pseudoDescriptors[keyInit] // for (const key in pseudoStyleObject) { // debugger // } // } passDownProp(viewProps, keyInit, valInit, isMediaOrPseudo) if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.groupEnd() } // if it's a variant here, we have a two layer variant... // aka styled(Input, { unstyled: true, variants: { unstyled: {} } }) // which now has it's own unstyled + the child unstyled... // so *don't* skip applying the styles if its different from the parent one if (!isVariant) { continue } } // after shouldPassThrough if (!noSkip) { if ( keyInit in skipProps && !( keyInit === 'transition' && typeof valInit === 'string' && !driver?.animations?.[valInit] ) ) { if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.groupEnd() } continue } } // we sort of have to update fontFamily all the time: before variants run, after each variant if (isText || isInput) { if ( valInit && (keyInit === 'fontFamily' || keyInit === shorthands['fontFamily']) && valInit in conf.fontsParsed ) { styleState.fontFamily = valInit } } const disablePropMap = isMediaOrPseudo || !isStyleLikeKey propMapper(keyInit, valInit, styleState, disablePropMap, (key, val, originalVal) => { const isStyledContextProp = styledContext && key in styledContext if (!isHOC && disablePropMap && !isStyledContextProp && !isMediaOrPseudo) { viewProps[key] = val return } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.groupCollapsed(' 💠 expanded', keyInit, '=>', key) log(val) console.groupEnd() } if (val == null) return if (process.env.TAMAGUI_TARGET === 'native') { if (key === 'pointerEvents') { viewProps[key] = val return } } if ( (!isHOC && isValidStyleKey(key, validStyles, accept)) || (process.env.TAMAGUI_TARGET === 'native' && isAndroid && key === 'elevation') ) { mergeStyle(styleState, key, val, 1, false, originalVal) return } // re-run with expanded key isPseudo = key in validPseudoKeys isMedia = isPseudo ? false : getMediaKey(key) isMediaOrPseudo = Boolean(isMedia || isPseudo) isVariant = variants && key in variants // handle group key transformation for variant-expanded keys (issue #3613) if (isMedia === 'group') { key = normalizeGroupKey(key, groupContext) } if ( inlineProps?.has(key) || (process.env.IS_STATIC === 'is_static' && inlineWhenUnflattened?.has(key)) ) { viewProps[key] = props[key] ?? val } // have to run this logic again here because expansions may need to be passed down // see StyledButtonVariantPseudoMerge test const shouldPassThrough = (styleProps.noExpand && isPseudo) || (isHOC && (isMediaOrPseudo || parentStaticConfig?.variants?.[keyInit])) if (shouldPassThrough) { passDownProp(viewProps, key, val, isMediaOrPseudo) if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.groupCollapsed(` - passing down prop ${key}`) log({ val, after: { ...viewProps[key] } }) console.groupEnd() } return } if (isPseudo) { if (!val) return // TODO can avoid processing this if !shouldDoClasses + state is off // (note: can't because we need to set defaults on enter/exit or else enforce that they should) const pseudoStyleObject = getSubStyle( styleState, key, val, styleProps.noClass && !(process.env.IS_STATIC === 'is_static') ) if (!shouldDoClasses || process.env.IS_STATIC === 'is_static') { pseudos ||= {} pseudos[key] ||= {} // if compiler we can just set this and continue on our way if (process.env.IS_STATIC === 'is_static') { Object.assign(pseudos[key], pseudoStyleObject) return } } const descriptor = pseudoDescriptors[key as keyof typeof pseudoDescriptors] const isEnter = key === 'enterStyle' const isExit = key === 'exitStyle' // don't continue here on isEnter && !state.unmounted because we need to merge defaults if (!descriptor) { return } // on server only generate classes for enterStyle if (shouldDoClasses && !isExit) { const pseudoStyles = getStyleAtomic(pseudoStyleObject, descriptor) if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.info('pseudo:', key, pseudoStyleObject, pseudoStyles) } for (const psuedoStyle of pseudoStyles) { const fullKey = `${psuedoStyle[StyleObjectProperty]}${PROP_SPLIT}${descriptor.name}` addStyleToInsertRules(rulesToInsert, psuedoStyle) classNames[fullKey] = psuedoStyle[StyleObjectIdentifier] } } if (!shouldDoClasses || isExit || isEnter) { // we don't skip this if disabled because we need to animate to default states that aren't even set: // so if we have // we need to animate from 0 => 1 once enter is finished // see the if (isDisabled) block below which loops through animatableDefaults const descriptorKey = descriptor.stateKey || descriptor.name let isDisabled = componentState[descriptorKey] === false if (isExit) { isDisabled = !styleProps.isExiting } if (isEnter && componentState.unmounted === false) { isDisabled = true } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { console.groupCollapsed('pseudo', key, { isDisabled }) log({ pseudoStyleObject, isDisabled, descriptor, componentState }) console.groupEnd() } const importance = descriptor.priority const pseudoOriginalValues = styleOriginalValues.get(pseudoStyleObject) for (const pkey in pseudoStyleObject) { const val = pseudoStyleObject[pkey] // when disabled ensure the default value is set for future animations to align if (isDisabled) { applyDefaultStyle(pkey, styleState) } else { const curImportance = styleState.usedKeys[pkey] || 0 const shouldMerge = importance >= curImportance if (shouldMerge) { if (process.env.IS_STATIC === 'is_static') { pseudos ||= {} pseudos[key] ||= {} pseudos[key][pkey] = val } mergeStyle( styleState, pkey, val, importance, false, pseudoOriginalValues?.[pkey] ) } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(' subKey', pkey, shouldMerge, { importance, curImportance, pkey, val, }) } } } // set this after the loop over pseudoStyleObject so it applies before setting usedKeys if (!isDisabled) { // mark usedKeys based on pseudoStyleObject for (const key in val) { const k = shorthands[key] || key styleState.usedKeys[k] = Math.max(importance, styleState.usedKeys[k] || 0) } } } return } // media if (isMedia) { if (!val) return // for some reason 'space' in val upsetting next ssr during prod build // technically i guess this also will not apply if 0 space which makes sense? const mediaKeyShort = key.slice(isMedia == 'theme' ? 7 : 1) hasMedia ||= true const hasSpace = val['space'] if (hasSpace || !shouldDoClasses || styleProps.willBeAnimated) { if (!hasMedia || typeof hasMedia === 'boolean') { hasMedia = new Set() } hasMedia.add(mediaKeyShort) } // can bail early if (isMedia === 'platform') { if (!isActivePlatform(key)) { return } } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(` 📺 ${key}`, { key, val, props, shouldDoClasses, acceptsClassName, componentState, mediaState, }) } const priority = mediaStylesSeen mediaStylesSeen += 1 // for theme media ($theme-light, $theme-dark), generate CSS classes for proper SSR // when noClass is set (inline animation drivers), de-opt to inline styles so the if (shouldDoClasses) { const mediaStyle = getSubStyle(styleState, key, val, false) const mediaStyles = getCSSStylesAtomic(mediaStyle) for (const style of mediaStyles) { // handle nested media: // for now we're doing weird stuff, getCSSStylesAtomic will put the // $platform-web into property so we can check it here const property = style[StyleObjectProperty] const isSubStyle = property[0] === '$' if (isSubStyle && !isActivePlatform(property)) { continue } const out = createMediaStyle( style, mediaKeyShort, mediaQueryConfig, isMedia, false, priority ) if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(`📺 media style:`, out) } // this is imperfect it should be fixed further down, we mess up property when dealing with // media-sub-style, like $sm={{ $platform-web: {} }} // property is just $platform-web, it should br $platform-web-bg, so we add extra info from style // but that info includes the value too const subKey = isSubStyle ? style[2] : '' const fullKey = `${ style[StyleObjectProperty] }${subKey}${PROP_SPLIT}${mediaKeyShort}${style[StyleObjectPseudo] || ''}` addStyleToInsertRules(rulesToInsert, out as any) classNames[fullKey] = out[StyleObjectIdentifier] } } else { const isThemeMedia = isMedia === 'theme' const isGroupMedia = isMedia === 'group' const isPlatformMedia = isMedia === 'platform' if (!isThemeMedia && !isPlatformMedia && !isGroupMedia) { if (!mediaState[mediaKeyShort]) { if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(` 📺 ❌ DISABLED ${mediaKeyShort}`) } return } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(` 📺 ✅ ENABLED ${mediaKeyShort}`) } } const mediaStyle = getSubStyle(styleState, key, val, true) let importanceBump = 0 if (isThemeMedia) { if ( process.env.TAMAGUI_TARGET === 'native' && isIos && getSetting('fastSchemeChange') ) { // iOS will use https://reactnative.dev/docs/dynamiccolorios // so need to predefine the dynamic color before merging the styles // for example: => {borderColor: {dynamic: {dark: '$red10', light: '$green10'}}} styleState.style ||= {} const scheme = mediaKeyShort const oppositeScheme = getOppositeScheme(mediaKeyShort) const themeOriginalValues = styleOriginalValues.get(mediaStyle) const isCurrentScheme = themeName === scheme || themeName.startsWith(scheme) for (const subKey in mediaStyle) { const val = extractValueFromDynamic(mediaStyle[subKey], scheme) const existing = styleState.style[subKey] // Only color properties support DynamicColorIOS - non-color properties // like opacity, dimensions, etc. will crash if wrapped with {dynamic: {...}} // See: https://github.com/tamagui/tamagui/issues/3096 // See: https://github.com/tamagui/tamagui/issues/2980 if (!isColorStyleKey(subKey)) { // non-color properties require re-render to update dynamicThemeAccess = true // only apply if this is the current theme if (isCurrentScheme) { // update mediaStyle so the later merge loop uses correct value mediaStyle[subKey] = val } else { // remove from mediaStyle so it doesn't get merged with wrong theme's value delete mediaStyle[subKey] } continue } // if there's already a dynamic object from the other theme pseudo prop, // merge directly to avoid importance conflicts between $theme-dark and $theme-light if (existing?.dynamic) { existing.dynamic[scheme] = val mediaStyle[subKey] = existing } else { const oppositeVal = extractValueFromDynamic(existing, oppositeScheme) mediaStyle[subKey] = getDynamicVal({ scheme, val, oppositeVal, }) mergeStyle( styleState, subKey, mediaStyle[subKey], priority, false, themeOriginalValues?.[subKey] ) } } } else { // non-ios or no fastschemechange - need re-renders for theme changes dynamicThemeAccess = true if (!(themeName === mediaKeyShort || themeName.startsWith(mediaKeyShort))) { return } } } else if (isGroupMedia) { const groupInfo = getGroupPropParts(mediaKeyShort) const groupName = groupInfo.name // $group-x const groupState = groupContext?.[groupName]?.state const groupPseudoKey = groupInfo.pseudo const groupMediaKey = groupInfo.media if (!groupState) { if (process.env.NODE_ENV === 'development' && debug) { log(`No parent with group prop, skipping styles: ${groupName}`) } // we still want to indicate we should listen! this is how subscribeToGroupContext knows to run pseudoGroups ||= new Set() return } const componentGroupState = componentState.group?.[groupName] if (groupMediaKey) { mediaGroups ||= new Set() mediaGroups.add(groupMediaKey) const mediaState = componentGroupState?.media let isActive = mediaState?.[groupMediaKey] // use parent styles if width and height hardcoded we can do an inline media match and avoid double render if (!mediaState && groupState.layout) { isActive = mediaKeyMatch(groupMediaKey, groupState.layout) } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log(` 🏘️ GROUP media ${groupMediaKey} active? ${isActive}`, { ...mediaState, usedKeys: { ...styleState.usedKeys }, }) } if (!isActive) { // ensure we set the defaults so animations work for (const pkey in mediaStyle) { applyDefaultStyle(pkey, styleState) } return } importanceBump = 2 } if (groupPseudoKey) { pseudoGroups ||= new Set() pseudoGroups.add(groupName) const componentGroupPseudoState = ( componentGroupState || // fallback to context initially groupContext?.[groupName].state )?.pseudo const isActive = componentGroupPseudoState?.[groupPseudoKey] const priority = pseudoPriorities[groupPseudoKey] if (process.env.NODE_ENV === 'development' && debug === 'verbose') { log( ` 🏘️ GROUP pseudo ${groupMediaKey} active? ${isActive}, priority ${priority}`, { componentGroupPseudoState: { ...componentGroupPseudoState }, usedKeys: { ...styleState.usedKeys }, } ) } if (!isActive) { // ensure we set the defaults so animations work for (const pkey in mediaStyle) { applyDefaultStyle(pkey, styleState) } return } importanceBump = priority } } else if (isPlatformMedia) { // Platform styles use specificity-based importance bumps so that more // specific platform selectors reliably win over broader ones regardless // of prop declaration order (e.g. $platform-tv always overrides // $platform-native for the same property, even if tv is listed first). importanceBump = getPlatformSpecificityBump(mediaKeyShort) } const mediaOriginalValues = styleOriginalValues.get(mediaStyle) // extract transition from group pseudo styles (e.g., $group-scenario4-hover.transition) if (isGroupMedia && mediaStyle.transition) { styleState.pseudoTransitions ||= {} styleState.pseudoTransitions[ `$${mediaKeyShort}` as keyof typeof styleState.pseudoTransitions ] = mediaStyle.transition as any } function mergeMediaStyle(key: string, val: any, originalVal?: any) { // on native, non-style keys from media queries (like numberOfLines) // need to go to viewProps, not style if (process.env.TAMAGUI_TARGET === 'native') { if (!isValidStyleKey(key, validStyles, accept)) { viewProps[key] = val return } } styleState.style ||= {} const didMerge = mergeMediaByImportance( styleState, mediaKeyShort, key, val, mediaState[mediaKeyShort], importanceBump, debug, originalVal ) if (didMerge && key === 'fontFamily') { styleState.fontFamily = mediaStyle.fontFamily as string } } for (const subKey in mediaStyle) { if (subKey === 'space') { continue } if (subKey[0] === '$') { const subMediaType = getMediaKey(subKey) if (subMediaType === 'platform') { if (!isActivePlatform(subKey)) continue } else if (subMediaType === 'theme') { if (!isActiveTheme(subKey, themeName)) continue } else if (subMediaType === true) { // regular media query nested inside platform/theme/media const subKeyShort = subKey.slice(1) if (!mediaState[subKeyShort]) continue } const nestedVal = mediaStyle[subKey] as Record const subOriginalValues = styleOriginalValues.get(nestedVal) // Nested styles are more specific than their outer context because // they require both conditions to be true. Calculate an importance // that is the sum of both the outer and inner importances so that: // 1) nested always beats non-nested // 2) $xs={{ $platform-android: ... }} and // $platform-android={{ $xs: ... }} produce identical importance // (last-declared wins for the same property) const isSizeMediaKey = !!mediaState[mediaKeyShort] const outerBase = isSizeMediaKey ? getMediaKeyImportance(mediaKeyShort) : defaultMediaImportance let innerBase: number if (subMediaType === 'platform') { innerBase = defaultMediaImportance + getPlatformSpecificityBump(subKey.slice(1)) } else if (subMediaType === true) { innerBase = getMediaKeyImportance(subKey.slice(1)) } else { innerBase = defaultMediaImportance } const nestedImportance = outerBase + importanceBump + innerBase + 1 for (const subSubKey in nestedVal) { // expand shorthands — getSubStyle doesn't expand keys // inside nested $ objects (they pass through propMapper as-is) const expandedKey = shorthands[subSubKey] || subSubKey const { usedKeys } = styleState if (usedKeys[expandedKey] && usedKeys[expandedKey] > nestedImportance) { continue } styleState.style ||= {} mergeStyle( styleState, expandedKey, nestedVal[subSubKey], nestedImportance, false, subOriginalValues?.[subSubKey] ) if (expandedKey === 'fontFamily') { styleState.fontFamily = nestedVal[subSubKey] as string } } } else { mergeMediaStyle(subKey, mediaStyle[subKey], mediaOriginalValues?.[subKey]) } } } return // end media } // pass to view props if (!isVariant) { if (isStyledContextProp) { return } viewProps[key] = val } }) if (process.env.NODE_ENV === 'development' && debug === 'verbose') { try { log(` ✔️ expand complete`, keyInit) log('style', { ...styleState.style }) log('viewProps', { ...viewProps }) log('transforms', { ...styleState.flatTransforms }) } catch { // RN can run into PayloadTooLargeError: request entity too large } console.groupEnd() } } // end prop loop if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`split-styles-propsend` } // style prop after: const avoidNormalize = styleProps.noNormalize === false if (!avoidNormalize) { if (styleState.style) { fixStyles(styleState.style) if (!styleProps.noExpand && !styleProps.noMergeStyle) { // shouldn't this be better? but breaks some tests weirdly, need to check if (isWeb && (isReactNative ? driver?.inputStyle !== 'css' : true)) { styleToCSS(styleState.style) } } } // these are only the flat transforms // always do this at the very end to preserve the order strictly (animations, origin) // and allow proper merging of all pseudos before applying if (styleState.flatTransforms) { // we need to match the order for animations to work because it needs consistent order // was thinking of having something like `state.prevTransformsOrder = ['y', 'x', ...] // but if we just handle it here its not a big cost and avoids having stateful things // so the strategy is: always sort by a consistent order, until you run into a "duplicate" // because you can have something like: // [{ translateX: 0 }, { scale: 1 }, { translateX: 10 }] // so basically we sort until we get to a duplicate... we could sort even smarter but // this should work for most (all?) of our cases since the order preservation really only needs to apply // to the "flat" transform props styleState.style ||= {} mergeFlatTransforms(styleState.style, styleState.flatTransforms) } // add in defaults if not set: if (parentSplitStyles) { if (process.env.TAMAGUI_TARGET === 'web') { if (shouldDoClasses) { for (const key in parentSplitStyles.classNames) { const val = parentSplitStyles.classNames[key] if ((styleState.style && key in styleState.style) || key in classNames) continue classNames[key] = val } } } if (!shouldDoClasses) { for (const key in parentSplitStyles.style) { if (key in classNames || (styleState.style && key in styleState.style)) continue styleState.style ||= {} styleState.style[key] = parentSplitStyles.style[key] } } } } // Button for example uses disableClassName: true but renders to a 'button' element, so needs this if (process.env.TAMAGUI_TARGET === 'web') { const shouldStringifyTransforms = !styleProps.noNormalize && !staticConfig.isReactNative && !staticConfig.isHOC && (!styleProps.isAnimated || driver?.inputStyle === 'css') if (shouldStringifyTransforms && Array.isArray(styleState.style?.transform)) { styleState.style.transform = transformsToString(styleState.style!.transform) as any } } if (process.env.TAMAGUI_TARGET === 'web') { if (!styleProps.noMergeStyle && styleState.style && shouldDoClasses) { let retainedStyles: ViewStyleWithPseudos | undefined let shouldRetain = false if (styleState.style['$$css']) { // avoid re-processing for rnw } else { const atomic = getCSSStylesAtomic(styleState.style) for (const atomicStyle of atomic) { const [key, value, identifier] = atomicStyle const isAnimatedAndTransitionOnly = styleProps.isAnimated && styleProps.noClass && props.animateOnly?.includes(key) // animateOnly properties should always use className on server and initial // client render to avoid hydration mismatch (server has isAnimated=false but // client has isAnimated=true for CSS driver, causing different style output) const nonAnimatedTransitionOnly = !isAnimatedAndTransitionOnly && !styleProps.isAnimated && isClient && driver?.outputStyle === 'css' && props.animateOnly?.includes(key) if (isAnimatedAndTransitionOnly) { retainedStyles ||= {} retainedStyles[key] = styleState.style[key] } else if (nonAnimatedTransitionOnly) { retainedStyles ||= {} retainedStyles[key] = value shouldRetain = true } else { addStyleToInsertRules(rulesToInsert, atomicStyle) classNames[key] = identifier } } if (process.env.NODE_ENV === 'development' && props.debug === 'verbose') { // console.groupEnd() // ensure group ended from loop above console.groupCollapsed(`🔹 getSplitStyles final style object`) console.info(styleState.style) console.info(`retainedStyles`, retainedStyles) console.groupEnd() } if (shouldRetain || !(process.env.IS_STATIC === 'is_static')) { styleState.style = retainedStyles || {} } } } // when noClass is true (inline animation driver) extract non-animatable // base styles to atomic CSS classNames so the driver doesn't manage them // skip for RNW animation drivers since their AnimatedView doesn't forward classNames if ( !styleProps.noMergeStyle && styleState.style && !shouldDoClasses && styleProps.isAnimated && !driver?.isReactNative ) { if (!styleState.style['$$css']) { const toConvert: Record = {} let hasProps = false const animateOnly = props.animateOnly as string[] | undefined for (const key in styleState.style) { if (key in nonAnimatableStyleProps) { toConvert[key] = styleState.style[key] delete styleState.style[key] hasProps = true } } if (hasProps) { const atomic = getCSSStylesAtomic(toConvert) for (const atomicStyle of atomic) { addStyleToInsertRules(rulesToInsert, atomicStyle) classNames[atomicStyle[StyleObjectProperty]] = atomicStyle[StyleObjectIdentifier] } } } } } // merge after the prop loop - and always keep it on style dont turn into className except if RN gives us const styleProp = props.style if (!styleProps.noMergeStyle && styleProp) { if (isHOC) { viewProps.style = normalizeStyle(styleProp) } else { const isArray = Array.isArray(styleProp) const len = isArray ? styleProp.length : 1 for (let i = 0; i < len; i++) { const style = isArray ? styleProp[i] : styleProp if (style) { if (style['$$css']) { Object.assign(styleState.classNames, style) } else { styleState.style ||= {} Object.assign(styleState.style, normalizeStyle(style)) } } } } } // native: swap out the right family based on weight/style if (process.env.TAMAGUI_TARGET === 'native') { // set accessible when tabIndex is 0 (issue #3350) if (viewProps.tabIndex === 0) { viewProps.accessible ??= true } const style = styleState.style if (style?.fontFamily) { const faceInfo = getFont(style.fontFamily as string)?.face if (faceInfo) { const overrideFace = faceInfo[style.fontWeight as string]?.[style.fontStyle || 'normal']?.val if (overrideFace) { style.fontFamily = overrideFace styleState.fontFamily = overrideFace // If we pass both font family (e.g. InterBold) and a font weight (e.g. 900), android gets confused and just shows the default font, so we remove these: delete style.fontWeight delete style.fontStyle } } if (process.env.NODE_ENV === 'development' && debug && debug !== 'profile') { log(`Found fontFamily native: ${style.fontFamily}`, faceInfo) } } } if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`split-styles-pre-result` } const result: GetStyleResult = { hasMedia, fontFamily: styleState.fontFamily, viewProps, style: styleState.style as any, pseudos, classNames, rulesToInsert, dynamicThemeAccess, pseudoGroups, mediaGroups, overriddenContextProps: styleState.overriddenContextProps, pseudoTransitions: styleState.pseudoTransitions, } const asChildExceptStyleLike = asChild === 'except-style' || asChild === 'except-style-web' if (!styleProps.noMergeStyle) { if (!asChildExceptStyleLike) { const style = styleState.style if (process.env.TAMAGUI_TARGET === 'web') { // merge className and style back into viewProps: // only emit font class if fontFamily was explicitly in props (not from defaults) let fontFamily = isText || isInput ? styleState.fontFamily : null if (fontFamily && fontFamily[0] === '$') { fontFamily = fontFamily.slice(1) } const fontFamilyClassName = fontFamily ? `font_${fontFamily}` : '' const groupClassName = props.group ? `t_group_${props.group}` : '' const componentNameFinal = props.componentName || staticConfig.componentName const componentNameClassName = props.asChild || !componentNameFinal || componentNameFinal === 'Text' ? '' : `is_${componentNameFinal}` let classList: string[] = [] if (componentNameClassName) classList.push(componentNameClassName) // is_View gets base flex styles + font reset, is_Text gets base text styles if (!isText) classList.push('is_View') else classList.push('is_Text') if (fontFamilyClassName) classList.push(fontFamilyClassName) if (classNames) classList.push(Object.values(classNames).join(' ')) if (groupClassName) classList.push(groupClassName) if (props.className) classList.push(props.className) const finalClassName = classList.join(' ') // use $$css for RNW components OR when animated with RNW driver // (driver's AnimatedView doesn't forward className) const needsCssStyles = isReactNative || (styleProps.isAnimated && driver?.isReactNative) if (styleProps.isAnimated && driver?.inputStyle === 'css') { // CSS animation driver uses className directly viewProps.className = finalClassName if (style) { viewProps.style = style as any } } else if (needsCssStyles) { // RNW or RNW-animated: apply classNames via $$css let cnStyles: Record | undefined for (const name of finalClassName.split(' ')) { cnStyles ||= { $$css: true } cnStyles[name] = name } viewProps.style = cnStyles ? [...(Array.isArray(style) ? style : [style]), cnStyles] : [style] } else { // regular web: use className directly if (finalClassName) { viewProps.className = finalClassName } if (style) { viewProps.style = style as any } } } else { if (style) { // native assign styles viewProps.style = style as any } } } } if (process.env.NODE_ENV === 'development' && debug === 'verbose') { if (isClient && isDevTools) { // end collapsed log above console.groupEnd() console.groupCollapsed('🔹 getSplitStyles ===>') try { // prettier-ignore const logs = { ...result, className, componentState, viewProps, rulesToInsert, parentSplitStyles, } for (const key in logs) { log(key, logs[key]) } } catch { // RN can run into PayloadTooLargeError: request entity too large } console.groupEnd() } } if ( process.env.NODE_ENV === 'development' && (debug === 'profile' || (globalThis as any).time) ) { // @ts-expect-error time`split-styles-done` } return result } function mergeFlatTransforms(target: TextStyle, flatTransforms: Record) { Object.entries(flatTransforms) .sort(([a], [b]) => sortString(a, b)) .forEach(([key, val]) => { mergeTransform(target, key, val, true) }) } function mergeStyle( styleState: GetStyleState, key: string, val: any, importance: number, disableNormalize = false, originalVal?: any ) { const { viewProps, styleProps, staticConfig, usedKeys } = styleState const existingImportance = usedKeys[key] || 0 if (existingImportance > importance) { return } // Track context overrides for pseudo/media styles (issues #3670, #3676) // When a style sets a key that's in context props, update overriddenContextProps // so it propagates to children. Use the original token value (like '$8') // instead of the resolved CSS variable (like 'var(--t-space-8)') // so children's functional variants can look up token values. const contextProps = staticConfig.context?.props || staticConfig.parentStaticConfig?.context?.props if (contextProps && key in contextProps) { styleState.overriddenContextProps ||= {} // Priority: 1) originalVal from propMapper, 2) tracked original from variant resolution, 3) val const originalFromState = styleState.originalContextPropValues?.[key] styleState.overriddenContextProps[key] = originalVal ?? originalFromState ?? val } if (key in stylePropsTransform) { styleState.flatTransforms ||= {} usedKeys[key] = importance styleState.flatTransforms[key] = val } else { const shouldNormalize = isWeb && !disableNormalize && !styleProps.noNormalize const out = shouldNormalize ? normalizeValueWithProperty(val, key) : val if ( // accept is for props not styles staticConfig.accept && key in staticConfig.accept ) { viewProps[key] = out } else { styleState.style ||= {} usedKeys[key] = importance styleState.style[key] = // if you dont do this you'll be passing props.transform arrays directly here and then mutating them // if theres any flatTransforms later, causing issues (mutating props is bad, in strict mode styles get borked) key === 'transform' && Array.isArray(out) ? [...out] : out } } } export const getSubStyle = ( styleState: GetStyleState, subKey: string, styleIn: object, avoidMergeTransform?: boolean ): TextStyle => { const { staticConfig, conf, styleProps } = styleState const styleOut: TextStyle = {} let originalValues: Record | undefined for (let key in styleIn) { const val = styleIn[key] key = conf.shorthands[key] || key // extract transition from pseudo-style props (e.g., hoverStyle.transition) // store it separately for animation drivers to use for enter/exit timing if (key === 'transition') { styleState.pseudoTransitions ||= {} styleState.pseudoTransitions[subKey as keyof typeof styleState.pseudoTransitions] = val // for CSS driver, also add transition to CSS output so native CSS transitions work // group styles ($group-*) need !important to override inline base transition const driver = styleState.animationDriver if (driver?.outputStyle === 'css') { const animationConfig = driver.animations?.[val as string] if (animationConfig) { const important = subKey[0] === '$' ? ' !important' : '' styleOut['transition'] = `all ${animationConfig}${important}` } } // not a known animation name, pass through as raw CSS if ( !styleOut['transition'] && typeof val === 'string' && !driver?.animations?.[val] ) { styleOut['transition'] = val } continue } const shouldSkip = !staticConfig.isHOC && key in skipProps && !styleProps.noSkip if (shouldSkip) { continue } propMapper(key, val, styleState, false, (skey, sval, originalVal) => { // Track original values for context prop propagation if (originalVal !== undefined) { originalValues ||= {} originalValues[skey] = originalVal } // pseudo inside media if (skey in validPseudoKeys) { sval = getSubStyle(styleState, skey, sval, avoidMergeTransform) } if (!avoidMergeTransform && skey in stylePropsTransform) { mergeTransform(styleOut, skey, sval) } else { styleOut[skey] = styleProps.noNormalize ? sval : normalizeValueWithProperty(sval, key) } }) } if (!avoidMergeTransform) { const parentTransform = styleState.style?.transform const flatTransforms = styleState.flatTransforms const styleOutTransform = styleOut.transform if (Array.isArray(styleOutTransform) && styleOutTransform.length) { // Inline conflict check - faster than building lookup object for small arrays const len = styleOutTransform.length if (Array.isArray(parentTransform)) { const merged: any[] = [] outer: for (let i = 0; i < parentTransform.length; i++) { const pt = parentTransform[i] for (const pk in pt) { for (let j = 0; j < len; j++) { for (const sk in styleOutTransform[j]) { if (pk === sk) continue outer break } } merged.push(pt) break } } for (let i = 0; i < len; i++) merged.push(styleOutTransform[i]) styleOut.transform = merged } if (flatTransforms) { outer: for (const fk in flatTransforms) { const ck = fk === 'x' ? 'translateX' : fk === 'y' ? 'translateY' : fk for (let j = 0; j < len; j++) { for (const sk in styleOutTransform[j]) { if (ck === sk) continue outer break } } mergeTransform(styleOut, fk, flatTransforms[fk]) } } } else if (flatTransforms) { mergeFlatTransforms(styleOut, flatTransforms) } } if (!styleProps.noNormalize) { fixStyles(styleOut) } // Store original values in WeakMap instead of on the object itself if (originalValues) { styleOriginalValues.set(styleOut, originalValues) } return styleOut } // on native no need to insert any css const useInsertEffectCompat = isWeb ? React.useInsertionEffect || useIsomorphicLayoutEffect : () => {} // perf: ...args a bit expensive on native export const useSplitStyles: StyleSplitter = (a, b, c, d, e, f, g, h, i, j, k, l, m) => { 'use no memo' const res = getSplitStyles(a, b, c, d, e, f, g, h, i, j, k, l, m) if (process.env.TAMAGUI_TARGET !== 'native') { useInsertEffectCompat(() => { if (res) { insertStyleRules(res.rulesToInsert) } }, [res?.rulesToInsert]) } return res } function addStyleToInsertRules(rulesToInsert: RulesToInsert, styleObject: StyleObject) { if (process.env.TAMAGUI_TARGET === 'web') { const identifier = styleObject[StyleObjectIdentifier] if (shouldInsertStyleRules(identifier)) { updateRules(identifier, styleObject[StyleObjectRules]) rulesToInsert[identifier] = styleObject } } } const defaultColor = process.env.TAMAGUI_DEFAULT_COLOR || 'rgba(0,0,0,0)' const animatableDefaults = { ...Object.fromEntries( Object.entries(tokenCategories.color).map(([k, v]) => [k, defaultColor]) ), opacity: 1, scale: 1, scaleX: 1, scaleY: 1, rotate: '0deg', rotateX: '0deg', rotateY: '0deg', rotateZ: '0deg', skewX: '0deg', skewY: '0deg', x: 0, y: 0, borderRadius: 0, } const mergeTransform = (obj: TextStyle, key: string, val: any, backwards = false) => { if (typeof obj.transform === 'string') { return } obj.transform ||= [] obj.transform[backwards ? 'unshift' : 'push']({ [mapTransformKeys[key] || key]: val, } as any) } const mapTransformKeys = { x: 'translateX', y: 'translateY', } function passDownProp( viewProps: object, key: string, val: any, shouldMergeObject = false ) { if (shouldMergeObject) { const next = { ...viewProps[key], ...val, } // need to re-insert it at current position delete viewProps[key] viewProps[key] = next } else { viewProps[key] = val } } function mergeMediaByImportance( styleState: GetStyleState, mediaKey: string, key: string, value: any, isSizeMedia: boolean, importanceBump?: number, debugProp?: DebugProp, originalVal?: any ) { const usedKeys = styleState.usedKeys let importance = getMediaImportanceIfMoreImportant( mediaKey, key, styleState, isSizeMedia ) if (importanceBump) { // With a specificity bump, the effective importance is always // defaultMediaImportance + bump. This lets higher-specificity styles // (e.g. $platform-tv > $platform-native) override lower-specificity ones // regardless of prop declaration order, even when getMediaImportanceIfMoreImportant // returns null (meaning the same base importance was already applied). // // We must re-check `usedKeys[key]` here (rather than relying on the null // returned by getMediaImportanceIfMoreImportant) because that function only // compares against `defaultMediaImportance`, which equals our base before // the bump. We need to compare against the *bumped* value to correctly // allow a more-specific style to win. const bumpedImportance = defaultMediaImportance + importanceBump importance = !usedKeys[key] || bumpedImportance > usedKeys[key] ? bumpedImportance : null } if (process.env.NODE_ENV === 'development' && debugProp === 'verbose') { log( `mergeMediaByImportance ${key} importance usedKey ${usedKeys[key]} next ${importance}` ) } if (importance === null) { return false } if (key in pseudoDescriptors) { const descriptor = pseudoDescriptors[key as PseudoDescriptorKey] const descriptorKey = descriptor.stateKey || descriptor.name const isDisabled = styleState.componentState[descriptorKey] === false if (isDisabled) { return false } // For pseudo inside media, value is an object with subkeys const pseudoOriginalValues = styleOriginalValues.get(value as object) for (const subKey in value) { mergeStyle( styleState, subKey, value[subKey], importance, false, pseudoOriginalValues?.[subKey] ) } } else { mergeStyle(styleState, key, value, importance, false, originalVal) } return true } function normalizeStyle(style: any) { const out: Record = {} for (const key in style) { const val = style[key] if (key in stylePropsTransform) { mergeTransform(out, key, val) } else { out[key] = normalizeValueWithProperty(val, key) } } if (isWeb && Array.isArray(out.transform)) { out.transform = transformsToString(out.transform) } fixStyles(out) return out } function applyDefaultStyle(pkey: string, styleState: GetStyleState) { const defaultValues = animatableDefaults[pkey] if ( defaultValues != null && !(pkey in styleState.usedKeys) && (!styleState.style || !(pkey in styleState.style)) ) { mergeStyle(styleState, pkey, defaultValues, 1) } }