Repository navigation
fix(native): combine a style prop's class, inline and !important values in one step - #487
YevheniiKotyrlo wants to merge 5 commits into
Conversation
…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.
What Chromium, Firefox and WebKit compute for each caseThe tests' classes, written and through .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
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
|
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.
…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.
9e8e881 to
3e43488
Compare
Problem
deepMergeConfigcombines 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!importantvalue over that result, flattened. So:{ className: "style" }and{ className: { target: "style" } }combine an inline value differently:style={{ backgroundColor: "blue" }}overbg-redgives{ backgroundColor: "blue" }against[{ backgroundColor: "#f00" }, { backgroundColor: "blue" }], and an inlinevars(),[],nullor{ height: undefined }leaves the class's style with the shorthand but is passed on with the object form.!importantclass over a one-key target other thanstyle, such ascontentContainerClassName, replaces both the inline value and the class's own declarations.!importantclass the inline style is flattened into a copy, so an animated style loses its identity: a ReanimateduseAnimatedStylehandle flattened beside other keys stands in for all of them.filterCssVariablesrebuilds every inline object, reading every property: anAnimated.Valuearrives as{ _value: 0.5, _offset: 0, … }, which no animation drives, an emptytransform: []is dropped, and auseAnimatedStylehandle's shared values are read during render, which Reanimated warns about.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!importantvalues 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.colorActivityIndicator'snativeStyleMappingwrites, takes the element's value over the class's and the!importantvalue over both, as atarget: falseconfig's props do onmain.filterCssVariablesremoves avars()object and an object'sundefinedkeys, keeps a style array'snullandundefinedlayers, 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!importantcases 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):style: an inline style that sets every key the class sets replaces it, and one that sets others is layered over it;styleand ofcontentContainerStyle: an inline value that adds no style leaves the class's; an inlinevars()adds no style of its own; an!importantclass 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;Animated.Valuein it, an emptytransform, aNaN, anundefinedarray layer and an accessor reach the component as they came, and the accessor never runs;!importantclass keeps all three, in order;nativeStyleMappingwrites, with a target offalse,style,contentContainerStyleor a nested path: the class's value, the element's over it, and an!importantclass's over both.components.test.tsx:TextInputtakes an!importantclass'stext-alignover its owntextAlign.Of the 71 cases this PR adds, 39 fail on
main, one of them for #416's callback style.Verification
ci.ymljob passes on this head merged intomain: the tests 1709 of 1709, and the iOS dev build on Xcode 27.1.inlineVariables: false, and with every stylesheet throughJSON.stringifyandJSON.parse, as a device receives it.styleattribute computes the value the test expects in Chromium 156, Firefox 157 and WebKit 27.2.mainwithin about 1 µs, and less beside an inlineAnimated.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.Valuesnippet onmainand here are in this comment.Merge order
getStyledPropsand settles the props anativeStyleMappingwrites into another prop, such ascontentContainerStyle.gap, key by key, where this PR leaves them asmaindoes; whichever lands second re-cuts.