Skip to content

fix(native): combine a style prop's class, inline and !important values in one step - #487

Draft
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:refactor/one-style-combine
Draft

YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:refactor/one-style-combine

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

deepMergeConfig combines a style prop's values in two passes and three places: the element's inline value over the class's, by how the mapping spells the target, then the class's !important value over that result, flattened. So:

  • { className: "style" } and { className: { target: "style" } } combine an inline value differently: style={{ backgroundColor: "blue" }} over bg-red gives { backgroundColor: "blue" } against [{ backgroundColor: "#f00" }, { backgroundColor: "blue" }], and an inline vars(), [], null or { height: undefined } leaves the class's style with the shorthand but is passed on with the object form.
  • An !important class over a one-key target other than style, such as contentContainerClassName, replaces both the inline value and the class's own declarations.
  • With an !important class the inline style is flattened into a copy, so an animated style loses its identity: a Reanimated useAnimatedStyle handle flattened beside other keys stands in for all of them.
  • filterCssVariables rebuilds every inline object, reading every property: an Animated.Value arrives as { _value: 0.5, _offset: 0, … }, which no animation drives, an empty transform: [] is dropped, and a useAnimatedStyle handle's shared values are read during render, which Reanimated warns about.
  • A new kind of style value has to be taught in each place, as feat(native): interop react-native-gesture-handler's Pressable and button family #416 does for a Pressable's callback style.

Solution

  • combineStyleLayers(computed, inline, important) combines a style prop's three values in one step, in the cascade's order, for every target whichever way the mapping spells it, through every object on its path. A value that adds no style (a falsy one, [], {}) is left out, and so is one the next value hides by setting every key it sets. The class's and !important values flatten into one object when no inline value stands between them; the inline value is a layer of its own, never copied; a callback style composes with the others, as feat(native): interop react-native-gesture-handler's Pressable and button family #416 does.
  • Every other prop a config computes, such as the color ActivityIndicator's nativeStyleMapping writes, takes the element's value over the class's and the !important value over both, as a target: false config's props do on main.
  • filterCssVariables removes a vars() object and an object's undefined keys, keeps a style array's null and undefined layers, returns a value it removes nothing from as it came, and reads only data properties, so an accessor such as a Reanimated shared value's never runs.

rightIsInline.test.tsx's two !important cases expected the flattened copy; they now check the flattened result, which is the same, and that the inline style stays a layer of its own.

Tests

className-with-style.test.tsx, each case with the class's values written, through a variable the compiler folds, and through one the runtime keeps (inlineVariables: false):

  • both spellings of style: an inline style that sets every key the class sets replaces it, and one that sets others is layered over it;
  • both spellings of style and of contentContainerStyle: an inline value that adds no style leaves the class's; an inline vars() adds no style of its own; an !important class layers over the inline value, which stays the same object, stands over a key the inline value sets, and flattens into the class's when no inline value stands between; combining writes to none of its values, and a re-render gives the same props;
  • an inline style, an Animated.Value in it, an empty transform, a NaN, an undefined array layer and an accessor reach the component as they came, and the accessor never runs;
  • a Pressable's callback style between a class and an !important class keeps all three, in order;
  • a prop a nativeStyleMapping writes, with a target of false, style, contentContainerStyle or a nested path: the class's value, the element's over it, and an !important class's over both.

components.test.tsx: TextInput takes an !important class's text-align over its own textAlign.

Of the 71 cases this PR adds, 39 fail on main, one of them for #416's callback style.

Verification

  • Every ci.yml job passes on this head merged into main: the tests 1709 of 1709, and the iOS dev build on Xcode 27.1.
  • The changed test files pass with every stylesheet compiled inlineVariables: false, and with every stylesheet through JSON.stringify and JSON.parse, as a device receives it.
  • An element with each case's classes and its inline style as the style attribute computes the value the test expects in Chromium 156, Firefox 157 and WebKit 27.2.
  • A re-render costs what it does on main within about 1 µs, and less beside an inline Animated.Value; the object form beside an inline style costs 1.2 µs more, what the shorthand costs, since it now combines the same way.

The engines' readings, the runs and the Animated.Value snippet on main and here are in this comment.

Merge order

…tton family

Gesture Handler's Pressable, its button family and PureNativeButton render
GestureHandlerButton, a codegen'd native view the react-native rewrite never
reaches, so className fell onto a view that declares no such prop.
DrawerLayoutAndroid wraps react-native's component, which has no styled
twin, and the Android GestureHandlerRootView renders a native view too.
react-native-css/components/react-native-gesture-handler re-declares these
with className mapped to the style each forwards, and the Metro resolver
routes react-native-gesture-handler to it. GestureHandlerRootView keeps its
flex: 1 default, which a resolved class would otherwise displace.

The resolver anchors its own-package check on the layout segment, since it
runs from src/metro under the source condition and from dist/<format>/metro
once built; a fixed level count sent this package's own components back
through the wrapper.

Pressable calls a function style with its pressed state, so the className
merge keeps a callback a callback on every merge path rather than merging it
into an array, where it never runs. The inline branch of the style-target
merge is removed: the length-one target merge after it always decides the
inline style.
…er suite

Jest's teardown reads every global, and expo's lazy fetch then loads the
renderer through the suite's react-native mock. With nativeFabricUIManager
still cleared, that throws, and on Linux Jest reports the suite as failed
to run.
…s flex:1 default

Gesture Handler's root falls back to `flex: 1` while no style is given,
and the re-declaration kept that default under every class. Yoga reads
a positive flex over an explicit or absent basis, so a root a class
sizes to its content — `grow-0`, `shrink-0`, `basis-*`, `flex-none` —
collapsed or stretched instead.

The default now yields to a class that states its own flex sizing, and
an inline style still displaces it, as Gesture Handler's own does.
… modules

The resolver computed the package root and the own-module test the babel
import plugin already computes, and its copy dropped the trailing path
separator, so a directory beside the package whose name merely begins
with dist or src counted as the package's own and its imports were left
unrewritten. Both now import one isFromThisModule.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

What Chromium, Firefox and WebKit compute for each case

The tests' classes, written and through :root { --spacing: 4px; --red: #ef4444; } variables:

.padded { padding: 4px; }
.bg-red { background-color: #ef4444; }
.bg-imp { background-color: #ef4444 !important; }
.themed { color: var(--brand, blue); }
.label { color: #ef4444; }
.label-imp { color: #ef4444 !important; }

Each case is a <div> with the test's classes and its inline style as the style attribute. Chromium 156, Firefox 157 and WebKit 27.2 compute the same values, written and through the variables, and each is the value the test expects of the React Native style (CSS Cascade 5 §6.1):

case class style computed
an inline style that sets every property the class sets replaces it bg-red background-color: blue background-color: rgb(0, 0, 255)
an inline style that sets other properties is layered over the class bg-red height: 16px background-color: rgb(239, 68, 68), height: 16px
the inline style is layered over the class as the same object padded height: 16px padding-top: 4px, padding-left: 4px, height: 16px
an Animated value in the inline style is the same instance padded opacity: 0.5 padding-top: 4px, opacity: 0.5
with no class style beside it, the inline style is the same object unstyled height: 16px padding-top: 0px, height: 16px
an inline value that adds no style leaves the class's padded (none) padding-top: 4px, padding-left: 4px
an inline vars() sets the variable and reaches the component as no style of its own padded themed --brand: red padding-top: 4px, color: rgb(255, 0, 0)
an !important class layers over the inline value, which is the same object padded bg-imp height: 16px padding-top: 4px, height: 16px, background-color: rgb(239, 68, 68)
an !important class's value stands over an inline value that sets it padded bg-imp background-color: blue; height: 16px padding-top: 4px, height: 16px, background-color: rgb(239, 68, 68)
an !important class layers over what the inline value keeps beside a CSS variable padded bg-imp height: 16px padding-top: 4px, height: 16px, background-color: rgb(239, 68, 68)
an !important class with no inline value flattens into the class's padded bg-imp (none) padding-top: 4px, background-color: rgb(239, 68, 68)
Pressable: a callback style between a class and an !important class keeps all three, in order padded bg-imp opacity: 1 padding-top: 4px, opacity: 1, background-color: rgb(239, 68, 68)
a prop a mapping writes: the class's value label (none) color: rgb(239, 68, 68)
a prop a mapping writes: the element's value over the class's label color: blue color: rgb(0, 0, 255)
a prop a mapping writes: an !important class's value alone label-imp (none) color: rgb(239, 68, 68)
a prop a mapping writes: an !important class's value over the element's label-imp color: blue color: rgb(239, 68, 68)
rightIsInline: important styles with inline VAR_SYMBOL objects important-color normal-size line-height: 24px color: rgb(255, 0, 0), font-size: 12px, line-height: 24px
rightIsInline: multiple important properties with mixed inline styles important-multi color: blue; font-size: 20px; font-weight: normal; line-height: 24px color: rgb(255, 0, 0), font-size: 14px, font-weight: 700, line-height: 24px
components: nativeStyleMapping with boolean true takes an !important class's value over the element's text-center text-align: left text-align: center

The identity, side-effect and repeat cases check what no engine computes: that an inline value reaches the component as the same object, and that combining writes to none of the values it reads.

An inline Animated.Value beside a class style — main against this PR

import { Animated } from "react-native";
import { styled } from "react-native-css";

const FadingView = styled(Animated.View, { className: "style" });
const opacity = new Animated.Value(0);
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: false }).start();

// .padded { padding: 4px; }
<FadingView className="padded" style={{ opacity }} />;

What Animated.View receives, read from its props, and the view's opacity once the timing has run (jest's fake timers, 400 ms):

style that opacity the view's opacity after the timing
main (2421ea77) [{ padding: 4 }, { opacity }], the second entry a copy of the inline style { __isNative: false, _value: 0, … }, a plain object that plain object: nothing animates
this PR [{ padding: 4 }, { opacity }], the second entry the inline style itself the same Animated.Value 1

The same holds for contentContainerStyle, for both spellings of each target, and beside an !important class, as className-with-style.test.tsx asserts.

Re-render cost — main against this PR

rerender-cost.test.tsx: each case re-renders 3000 times after 200 of warm-up, in a jest process of its own after a warm-up case, started at high priority on the same sixteen logical processors for both sides. One warm-up round, then ten rounds, the side that goes first swapping each round. µs per re-render, medians with interquartile ranges:

case main (2421ea77) this PR per-round difference rounds faster
a class style alone 24.1 (22.8–25.1) 23.9 (23.3–24.8) −0.5 (−0.9 to +0.2) 6 of 10
a class style beside an inline style 24.9 (24.3–26.0) 25.3 (24.8–26.6) +0.3 (−0.4 to +1.5) 3 of 10
a class style beside an inline Animated value 27.0 (26.8–27.8) 25.7 (25.1–27.2) −1.3 (−2.4 to +0.3) 7 of 10
a Pressable's class style beside a style function 34.3 (33.8–34.9) 34.9 (34.0–35.2) +0.5 (−0.1 to +1.5) 3 of 10
the object form: a class style beside an inline style 23.7 (23.4–25.0) 24.9 (24.4–27.1) +1.2 (+0.8 to +1.6) 2 of 10
a class style and an !important class beside an inline style 25.9 (25.7–30.6) 25.9 (25.3–28.1) −0.5 (−1.8 to +2.3) 6 of 10

A class style alone, which every case starts from, puts the rounds' spread at about 1 µs. Every case but one costs what it does on main within it, and an inline Animated.Value costs less, in seven rounds of ten. The object form beside an inline style costs 1.2 µs more, in eight rounds of ten: 24.9, what the shorthand costs here (25.3), since it now combines as the shorthand does, where main layered the two without checking which keys the inline style sets. On main the Pressable case resolves no style function, which #416 adds.

Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.12.1, jest 29.7.0.

@YevheniiKotyrlo YevheniiKotyrlo changed the title refactor(native): combine two values of a style prop in one place fix(native): combine a style prop's class, inline and !important values in one step Oct 21, 2026
…es in one step

deepMergeConfig combined a style prop's values in two passes and three
places, so the two spellings of a target combined an inline value
differently, an !important class over a target other than style replaced
the inline value and the class's own declarations, and an inline style
was copied before it reached the component.

combineStyleLayers combines the class's value, the element's inline value
and the class's !important value in the cascade's order, for every
target whichever way the mapping spells it, through every object on its
path; the inline value is a layer of its own, never copied, and a
callback style composes with the others. Every other prop a config
computes takes the element's value over the class's and the !important
value over both, as a target:false config's props do.

filterCssVariables returns a value it removes nothing from as it came and
reads only data properties, so an inline style, an Animated.Value in it
and a Reanimated animated style reach the component as they were.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the refactor/one-style-combine branch from 9e8e881 to 3e43488 Compare October 27, 2026 00:51

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant