Skip to content

fix: keep a list's separator when it is read through a variable - #547

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/list-separator
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/list-separator

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A comma-separated list compiles to the same array as a space-separated one whenever each item is one token: reduceParseUnparsed unwraps a one-token group, as #24 made it, so --t: width, opacity and --t: width opacity both store ["width", "opacity"]. At runtime a var() 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:

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(--s) #456 over --s: 1px 1px 0 0 #123, 2px 2px 0 0 a second shadow with no colour 2px 2px 0 0 #456
transform: var(--t) over --t: rotate(45deg) translateX(10px) [[{ rotate }, { translateX }]], which React Native refuses [{ 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] }], which React Native refuses [{ scaleX: 2 }, { scaleY: 3 }]
linear-gradient(to right, var(--c)) over --c: #123, #456 linear-gradient(to right, #123 #456) linear-gradient(to right, #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)
filter: var(--f) over --f: blur(2px), brightness(1.5) both filters none, as a filter list takes no comma

Solution

  • reduceParseUnparsed keeps 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, parseUnresolvedColor and an @property comma multiplier build their lists the same way, and the stylesheet builder still marks a list for runtime resolution when a function such as var() sits inside one of its items.
  • parseTransform writes translate()'s second offset flat, as it writes scale()'s; main nests it, so React Native refuses even a written translate(10px, 20px). For a function it has no form for, such as translateZ(), it writes nothing instead of an empty list.
  • resolveValue substitutes a var() by its tokens, as css-variables-1 §3 does: a list joins the item the var() stands in, and a comma-separated one splits that item.
  • boxShadow reads a comma-separated list's items as shadows. filter and transform take 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 return null, which unsets an earlier rule's value. The function printer joins items with , and tokens with a space. translate(), scale() and rotate() read each written argument in its place, or the items of the comma-separated list a lone var() resolves to, and the timing functions and platformColor read their arguments' items.
  • applyValue and useNativeVariable hand out a comma-separated list with one-token items bare, the form React Native's list props take, so transitionProperty: var(--p) gets what it gets today.

Tests

src/__tests__/native/variable-lists.test.tsx renders 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 the translateZ() case expects the list without the function the compiler drops. It also resolves resolved lists again, as a variable read twice is. compiler.test.tsx checks that blue, green, blue ,green and blue/**/, green compile apart from blue green, box-shadow: var(--a), var(--b) apart from var(--a) var(--b), and translate(10px, 20px) and translateZ(10px) rotate(45deg) to one function per token; property.test.ts checks that a <length># initial value keeps its items apart. 30 of these 69 cases fail on main and none here.

Verification

Every job of the workflow passes with this branch merged into main, build-ios-dev included.

resolveValue runs on every render, so I timed calculateProps on list rules in Hermes and Node against main: each rule main renders right stays within the noise floor or gets faster, and transform: var(--t) over two functions, which main renders as a nested list, costs 7% more in Hermes. The numbers, and this PR's cases run on main and 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:

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.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

main against this PR: on a device, its cases and its cost

On a device. iPhone 12 Pro Max, iOS 27.0.1, the library's example app built Release with Xcode 27.1 against main (2421ea77) and against this branch (2194ead9), both frames from one session. Both sides carry expo 57.0.31 and the UIKit scene life cycle, which the example needs to build and launch with Xcode 27.1. Each box's variables are declared in :root and in the box, so the runtime resolves every list.

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.

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