Repository navigation
fix: keep a list's separator when it is read through a variable - #547
YevheniiKotyrlo wants to merge 1 commit into
Conversation
A comma-separated list compiled to the same array as a space-separated one whenever each item was a single token, so the runtime guessed the separator from how its arrays nested: box-shadow: var(--o) var(--b) drew the second variable as a shadow of its own, a transform held in a variable reached React Native as a nested list, and a comma in a filter or transform list went unnoticed. The compiler keeps each item of a comma-separated list as the array of its tokens, and the runtime substitutes a var() by its tokens as css-variables-1 §3 does, so box-shadow, filter, transform, the arguments of translate(), scale() and rotate() and the function printer read the separator a list was written with. A transform list holds one function per token: translate()'s second offset, which React Native refused nested, joins the list, and a function the compiler writes no form for drops out without taking the list with it. React Native's list props still take a comma-separated list with one-token items bare.
|
main |
this branch |
|---|---|
![]() |
![]() |
From the top: the control shadow draws alike, its pixels within 1 of 255 across the two sides. var(--offset) var(--rest) over 6px 6px and 4px 0 #1565c0 draws one blurred blue shadow, where main keeps the second variable alone; var(--shadows) #c62828 gives the split item its colour, where main draws it black; the gradient's comma-separated stops draw, where main draws none; rgb(var(--rgb) / 0.5) fills the box half-transparent red, where main prints a colour React Native refuses and the box loses its fill; and transform: var(--transform) and translate(var(--translate)) over 24px, 12px move the box, where main hands React Native a nested list and an empty one.
The cases. This PR's tests, run on this branch (2194ead9) and on main (2421ea77) with variable-lists.test.tsx and the new compiler cases copied over: of the 69 cases in variable-lists.test.tsx, compiler.test.tsx's separator and transform-list tables and property.test.ts's <length># case, 30 fail on main and none here.
With every variable left for the runtime to resolve, as it is when declared in more than one rule or set by an ancestor, calculateProps gives:
main |
this branch | |
|---|---|---|
box-shadow: var(--o) var(--b) over --o: 1px 2px; --b: 3px 4px #123 |
3px 4px #123 |
1px 2px 3px 4px #123 |
box-shadow: var(--a) over --b: 1px 2px; --a: var(--b) 3px 4px #123 |
no shadow | 1px 2px 3px 4px #123 |
box-shadow: var(--missing, 1px 2px) 3px 4px #123 |
no shadow | 1px 2px 3px 4px #123 |
box-shadow: var(--s) #456 over --s: 1px 1px 0 0 #123, 2px 2px 0 0 |
2px 2px 0 0 with no colour |
2px 2px 0 0 #456 |
transform: var(--t) over --t: rotate(45deg) translateX(10px) |
[[{ rotate }, { translateX }]] |
[{ rotate }, { translateX }] |
transform: translate(var(--t)) over --t: 10px, 20px |
no transform | [{ translateX: 10 }, { translateY: 20 }] |
transform: scale(var(--s)) over --s: 2, 3 |
[{ scale: [2, 3] }] |
[{ scaleX: 2 }, { scaleY: 3 }] |
transform: translate(var(--x)) over --x: 10px |
no transform | [{ translateX: 10 }] |
linear-gradient(to right, var(--c)) over --c: #123, #456 |
linear-gradient(to right, #123 #456) |
linear-gradient(to right, #123, #456) |
linear-gradient(var(--c)) over --c: #123 #456 |
linear-gradient(#123, #456) |
linear-gradient(#123 #456) |
background-color: rgb(var(--c) / 0.5) over --c: 255 0 0 |
rgb(255 0 0, 0.5) |
rgb(255 0 0 / 0.5) |
box-shadow: var(--o) var(--b) over --o: 1px 2px 0 0 #123; --b: 3px 4px 0 0 #456 |
two shadows | none |
filter: var(--f) over --f: blur(2px), brightness(1.5) |
both filters | none |
filter: var(--a), var(--missing) over --a: blur(2px) |
blur(2px) |
none |
transform: var(--t) over --t: rotate(45deg), translateX(10px) |
[[{ rotate }, { translateX }]] |
none |
Each result on this branch is what Chromium 156, Firefox 157 and WebKit 27.2 compute with getComputedStyle for the declaration written out and through the variable.
The last four are lists their property refuses, so the browsers drop the declaration and an earlier rule's value with it, which this branch's null does: two shadows need a comma between them, a filter or transform list takes none, and var(--missing) with no fallback makes the whole list invalid. main cannot tell the space-separated transform from the comma-separated one, so it gives both the same nested list.
React Native's processTransform refuses main's nested lists and { scale: [2, 3] }. Its processBackgroundImage draws no gradient from linear-gradient(to right, #123 #456), so main draws none for the comma-separated stops; for linear-gradient(var(--c)) over #123 #456 the browsers refuse the space-separated stops, and so does processBackgroundImage with this branch's linear-gradient(#123 #456), where main printed commas and drew a gradient.
Written out, transform: translate(10px, 20px) compiles on main to [{ translateX: 10 }, [{ translateY: 20 }]] and translateZ(10px) rotate(45deg) to [[], { rotate }], which processTransform refuses too; here they compile to [{ translateX: 10 }, { translateY: 20 }] and [{ rotate }].
To rerun, copy src/__tests__/native/variable-lists.test.tsx onto main and run it with yarn jest.
The cost. calculateProps on each rule below with its variables left to the runtime, ns per call, on an AMD Ryzen 9 9950X3D under Windows 11. main and this branch run in rounds of main, branch, branch, main, each run a fresh process that times every rule in rounds of its own, so drift on the machine lands on both sides. Each figure is the median of every timing round of a side's runs, and in brackets the spread between its runs' medians, largest less smallest, which is the noise floor.
Hermes, the engine React Native runs, in four rounds, eight runs a side: its CLI v0.13.0, the latest facebook/hermes publishes (each React Native this library supports, from 0.81, ships a newer Hermes), with each side's runtime transformed as Metro transforms it for Hermes and compiled with hermesc -O, as a release build is.
| rule | main |
this branch | |
|---|---|---|---|
Tailwind v4's five-layer box-shadow: var(--tw-inset-shadow), … |
47,293 (1,118) | 47,550 (1,254) | +0.5% |
linear-gradient(var(--stops)) over var(--position), var(--from) 0%, var(--via) 50%, var(--to) 100% |
17,005 (769) | 16,024 (472) | −5.8% |
Tailwind v4's transform: var(--tw-rotate-x,) var(--tw-rotate-y,) …, one of them set |
14,993 (203) | 15,029 (276) | +0.2% |
Tailwind v4's filter: var(--tw-blur,) …, nine variables, one of them set |
23,611 (596) | 23,327 (513) | −1.2% |
filter: var(--f) over blur(2px) brightness(1.5) |
7,865 (169) | 7,995 (206) | +1.6% |
color: var(--c) |
3,954 (89) | 4,024 (103) | +1.8% |
transform: var(--t) over rotate(45deg) translateX(10px) |
7,921 (98) | 8,509 (148) | +7.4% |
Two rules move past the floor. linear-gradient(var(--stops)) gets faster. transform: var(--t), which main renders as the nested list React Native refuses, costs 7.4% more, for transform's check for a comma and for rotate() reading its argument through the argument reader the transform functions now share.
Node 26.12.1, outside jest, in two rounds, four runs a side:
| rule | main |
this branch | |
|---|---|---|---|
Tailwind v4's five-layer box-shadow |
3,081 (143) | 2,809 (145) | −8.8% |
linear-gradient(var(--stops)) |
1,731 (102) | 1,164 (32) | −32.7% |
Tailwind v4's transform |
1,127 (28) | 1,083 (56) | −3.9% |
Tailwind v4's filter |
1,543 (80) | 1,346 (42) | −12.8% |
filter: var(--f) |
852 (27) | 659 (45) | −22.6% |
color: var(--c) |
515 (18) | 505 (19) | −1.9% |
transform: var(--t) |
770 (25) | 744 (42) | −3.4% |
I timed outside jest because its sandbox makes each lookup of a global cost far more than the code either side runs: about 63 ns for Array.isArray against 3 ns for the same function held in a local. To rerun both tables, take this gist and run bash rounds.sh <react-native-css checkout> 2421ea77 2194ead9 <folder holding hermes and hermesc> <output folder> 4.


Problem
A comma-separated list compiles to the same array as a space-separated one whenever each item is one token:
reduceParseUnparsedunwraps a one-token group, as #24 made it, so--t: width, opacityand--t: width opacityboth store["width", "opacity"]. At runtime avar()holding a list resolves to an array nested in the list that reads it, and each reader guesses the separator from how the arrays nest.So a list renders wrong when read through a variable the runtime resolves: one declared in more than one rule, as a theme's are, one an ancestor sets, or one kept with
inlineVariables: false. Each result on this branch is what Chromium, Firefox and WebKit compute:mainbox-shadow: var(--o) var(--b)over--o: 1px 2px; --b: 3px 4px #1233px 4px #1231px 2px 3px 4px #123box-shadow: var(--a)over--b: 1px 2px; --a: var(--b) 3px 4px #1231px 2px 3px 4px #123box-shadow: var(--s) #456over--s: 1px 1px 0 0 #123, 2px 2px 0 02px 2px 0 0 #456transform: var(--t)over--t: rotate(45deg) translateX(10px)[[{ rotate }, { translateX }]], which React Native refuses[{ rotate }, { translateX }]transform: translate(var(--t))over--t: 10px, 20px[{ translateX: 10 }, { translateY: 20 }]transform: scale(var(--s))over--s: 2, 3[{ scale: [2, 3] }], which React Native refuses[{ scaleX: 2 }, { scaleY: 3 }]linear-gradient(to right, var(--c))over--c: #123, #456linear-gradient(to right, #123 #456)linear-gradient(to right, #123, #456)background-color: rgb(var(--c) / 0.5)over--c: 255 0 0rgb(255 0 0, 0.5)rgb(255 0 0 / 0.5)filter: var(--f)over--f: blur(2px), brightness(1.5)Solution
reduceParseUnparsedkeeps each item of a list of two or more as the array of its tokens, as before fix: parsing of rules with comma seperated styles #24; a lone item stays bare.parseGradient,parseUnresolvedColorand an@propertycomma multiplier build their lists the same way, and the stylesheet builder still marks a list for runtime resolution when a function such asvar()sits inside one of its items.parseTransformwritestranslate()'s second offset flat, as it writesscale()'s;mainnests it, so React Native refuses even a writtentranslate(10px, 20px). For a function it has no form for, such astranslateZ(), it writes nothing instead of an empty list.resolveValuesubstitutes avar()by its tokens, as css-variables-1 §3 does: a list joins the item thevar()stands in, and a comma-separated one splits that item.boxShadowreads a comma-separated list's items as shadows.filterandtransformtake a space-separated list (filter-effects-1 §5, css-transforms-1 §4), so a comma makes the declaration invalid at computed-value time and they returnnull, which unsets an earlier rule's value. The function printer joins items with,and tokens with a space.translate(),scale()androtate()read each written argument in its place, or the items of the comma-separated list a lonevar()resolves to, and the timing functions andplatformColorread their arguments' items.applyValueanduseNativeVariablehand out a comma-separated list with one-token items bare, the form React Native's list props take, sotransitionProperty: var(--p)gets what it gets today.Tests
src/__tests__/native/variable-lists.test.tsxrenders each case through a variable the compiler folds and through one it keeps (inlineVariables: false), and each expected value is what Chromium, Firefox and WebKit compute, except that thetranslateZ()case expects the list without the function the compiler drops. It also resolves resolved lists again, as a variable read twice is.compiler.test.tsxchecks thatblue, green,blue ,greenandblue/**/, greencompile apart fromblue green,box-shadow: var(--a), var(--b)apart fromvar(--a) var(--b), andtranslate(10px, 20px)andtranslateZ(10px) rotate(45deg)to one function per token;property.test.tschecks that a<length>#initial value keeps its items apart. 30 of these 69 cases fail onmainand none here.Verification
Every job of the workflow passes with this branch merged into
main,build-ios-devincluded.resolveValueruns on every render, so I timedcalculatePropson list rules in Hermes and Node againstmain: each rulemainrenders right stays within the noise floor or gets faster, andtransform: var(--t)over two functions, whichmainrenders as a nested list, costs 7% more in Hermes. The numbers, and this PR's cases run onmainand here: #547 (comment)No issue or pull request tracks this; I searched for comma, separator, list and variable beside box-shadow, transform, filter and transition.
Merge order
This changes how a list compiles and resolves, so it shares lines with #389, #391, #411, #413, #417, #420, #421, #489, #495, #497, #501 and #503; I'll rebase whichever of us lands second. Where more than lines meet:
var()'s list inside one reader (resolveOperands,readCommaGroups,flat()), which this does once inresolveValue; whichever lands second drops its splice.translate()'s second offset inparseTransformand reads the transform functions apart from the properties; whichever lands second keeps oneparseTransformand reads a function's arguments as the items this compiles.parseUnresolvedColorwrites, which this writes as comma-separated items; whichever lands second keeps fix(compiler): an unresolved alpha corrupts the colour beside it #421's channels as items.filterortransformlist it cannot read withundefined, where this returnsnull; whichever lands second makes them one.fontFamilyinapplyValue, beside the flattening of comma-separated lists this adds there.