#4194·tamagui

@tamagui/static: extractToClassNames emits shadowed/mis-merged className branches when one element has two conditional style props on different conditions

Author: 2eazyCreated Aug 30, 2026Updated Aug 30, 2026

Which component

@tamagui/static (compiler / extraction), v2.4.6.

Summary

When one JSX element carries two or more conditional style props whose test expressions differ, extractToClassNames' expandTernary emits a className ternary chain that selects the wrong styles for at least one combination of those conditions. The element renders correctly in dev and in any build with extraction disabled, and incorrectly in a production build — so nothing short of reading the compiler's output catches it.

One conditional prop, or several keyed on the same condition (normalizeTernaries merges those into a single ternary), never reach the affected path and are unaffected.

Reproduction

typescript
import { SizableText } from 'tamagui'

export function Fixture({ bold, shown, label }) {
  return (
    <SizableText size="$2" fontWeight={bold ? '600' : '500'} display={shown ? 'flex' : 'none'}>
      {label}
    </SizableText>
  )
}

Emitted by extractToClassNames (2.4.6):

javascript
<span className={
     !!bold && shown ? _cn  : !bold && !shown ? _cn2 : !!bold && shown ? _cn3
   : !bold && shown  ? _cn4 : !bold && shown  ? _cn5 : bold && !shown  ? _cn6
   : !bold && shown  ? _cn7 : bold && shown   ? _cn8 : !bold ? _cn9 : bold ? _cn0 : _cn1
}>

Note !!bold && shown and !bold && shown each appearing several times with different class strings; the first occurrence wins. Evaluating the chain:

bold shown selected source asks for
false false _fw-f-weight-4 _dsp-none _fw-500 _dsp-none
false true _fw-f-weight-4 _dsp-flex _fw-500 _dsp-flex
true false _fw-f-weight-4 _dsp-none _fw-600 _dsp-none
true true _fw-f-weight-4 _dsp-none _fw-600 _dsp-flex

Both props are wrong. fontWeight never resolves to either literal in any combination; display is wrong in the both-true cell.

Three separable causes, all in expandTernary

1. A branch test built from the wrong polarity. In the ternary.alternate block:

javascript
if (prev) {
  expandedTernaries.push({
    fontFamily,
    test: t.logicalExpression("&&", t.unaryExpression("!", prev.test), ternary.test),
    consequent: ternary.alternate,   // <- the ALTERNATE's styles ...
    ...
  });
}

The test says !prev.test && ternary.test — the consequent's condition — while the entry carries the alternate's styles. It needs !ternary.test. Because the chain is assembled by wrapping each new branch around the previous one (ternaryClassNameExpr = t.conditionalExpression(ternary.test, literal, ternaryClassNameExpr)), this branch is pushed last and therefore tested first, shadowing the correct branch below it.

2. Base styles inside each arm clobber the previous ternary's contributions. Each ternary's arms arrive already merged with the base style (mergeProps(mergeForwardBaseStyle, arm), earlier in the same function). So mergeProps(prev.consequent, ternary.consequent) lets the base values carried inside the second ternary's arm overwrite the first ternary's real values. This is why fontWeight degrades to the font token in every combination rather than only in the shadowed one — and it is independent of cause 1: fixing 1 alone leaves this column completely unchanged.

3. The !prev.test fallback entries carry only the current ternary's own styles and shadow the correct cross-product entries produced from the other prev entry.

Suggested direction

Building the cross-product explicitly, rather than accumulating incrementally, fixes all three: carry both arms of every ternary (using an empty style for an arm the source omits, so the accumulator always partitions the whole condition space), combine with prev pairwise, and strip from an arm the keys it merely inherited from the base before merging it over prev. That yields 2^n mutually-exclusive branches whose order does not matter.

We are carrying a patch of that shape. Compiling our whole extracted population (434 files) before and after, only the 3 files containing a genuinely affected element changed; 431 were byte-identical.

Impact as we found it

Three user-visible defects live in production, none reported by users because each degrades quietly: a navigation tab label permanently display:none, a mail folder that is both selected and unread rendering in the inactive colour, and sub-headings losing their background and padding.