Skip to content

fix(metro): compile the native stylesheet from the authored CSS, never its web build - #461

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/metro-native-stylesheet-input
Open

YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/metro-native-stylesheet-input

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The Metro transformer compiles the web build of a stylesheet for a device, so a native bundle receives CSS lowered for the project's browserslist — which is why every rtl: / ltr: rule is dropped (#453), plus two more effects nobody has reported.

// src/metro/metro-transformer.ts
const cssFile = (await worker.transform(config, projectRoot, filePath, data, {
  ...options,
  platform: "web",
})) as TransformResponse & { output: [{ data: { css: { code: Buffer } } }] };

const css = cssFile.output[0].data.css.code.toString();

That transform is @expo/metro-config's transformCss: PostCSS, then Sass, then lightningcss with targets: browserslistToTargets(browserslist(…, { path: projectRoot })). So the native compiler is handed the stylesheet as lowered for the project's browsers — or for browserslist's defaults when the project declares none. Measured on a real Tailwind v4 entry:

the lowering what it does on native
:dir(rtl):is(:lang(ae), :lang(ar), …) the compiler has no reading for it, so 15 rtl: / ltr: rules are dropped — this is #453
oklch()lab(), plus a hex-fallback :root beside a @supports copy every root variable is declared twice, so inlineVariables inlines none and 331 colour utilities compile to a runtime var() lookup
transform-origin: left top 30px re-serialised compiles to ["0%", 30, 0] instead of [0, 0, 30]

None of it is visible in the authored CSS, and all of it moves with the browserslist: once a project's window rolls past Chrome 119 its rtl: classes start working on native with no change to anything.

Solution

The native path needs two of the web transform's three steps, not its output. The transformer runs the project's PostCSS and Sass through the same Expo modules the web transform calls, and compiles their output — no browser build is produced for a native stylesheet.

const { src } = await transformPostCssModule(projectRoot, { src: source, filename: filePath });
const syntax = matchSass(filePath);
const css = syntax ? compileSass(projectRoot, { filename: filePath, src }, { syntax }).src : src;

transformPostCssModule resolves and caches the project's postcss.config.* exactly as the web transform does, so a plugin runs for a device iff it runs for a browser. The third step — lightningcss — also collected @import dependencies, and the transformer already discarded them (it returns the injection module's own transform), so nothing the native path had is lost.

compile() itself was never wrong: its own lightningcss pass sets no targets and preserves :dir() verbatim, measured on 1.30.1. It was handed the wrong input — which is why Features.DirSelector in the compiler's include list does not help.

Tests

src/__tests__/metro/native-stylesheet-input.test.ts drives the real transform against a stand-in for Expo's worker whose web branch does what Expo's does: the project's PostCSS, then lightningcss against one browser without :dir() support (chrome: 100), so the lowering happens for real rather than being imitated. The project is a directory with a postcss.config.json naming @tailwindcss/postcss, a @theme colour declared once in oklch(), and a stylesheet generating bg-[#00f] rtl:bg-[#f00] bg-brand.

Eight cases. Two are red on main and are the bug's own shape — rtl:bg-[#f00] carries the dir condition with the worker never asked for a web build, and bg-brand inlines to a literal. The other six pin what must not move: the injected stylesheet is what PostCSS produced, the stylesheet's own output is emptied and skipCached, and a web build, a non-stylesheet module and a stylesheet requested as an asset each reach the worker untouched.

native-stylesheet-plain-project.test.ts is a second project with no PostCSS config at all, in its own file because Expo resolves a project's pipeline once per process.

native-stylesheet-sass.test.ts is a third, authoring .scss — a $variable and a nested &.nested, neither of which is CSS. It stands in for Expo's Sass helper, which resolves the optional sass package from the project root and throws when it is absent, and asserts the hand-off: which syntax the transformer names, and that the compiler receives what Sass produced rather than the authored source.

It exists because the arm was measurably unobservable — deleting the compileSass call outright left all 10 earlier cases green. The same deletion now reddens all three.

No test in the suite compiles a stylesheet whose authored CSS differs from its browser build — every fixture survives a browser pass unchanged, which is why this has never surfaced here.

Mutation-proved: reverting the src/ diff and re-running these files alone turns 6 of 13 red.

Verification

yarn test src/__tests__/metro 13 passed · yarn typecheck 0 · yarn lint 0

Also measured and deliberately not changed: a stylesheet Expo's web pass would have recovered from with errorRecovery — a malformed selector, a stray brace, an unknown at-rule — compiles to the same surviving rules, so a device does not become stricter about broken CSS than a browser was.

Known limits

Closes #453. That report places the lowering inside compile()'s own lightningcss call; it happens one hop earlier.

Base

Branched off f70c402. main has since taken #451 (a5002c5). 1 of the 9 files this changes also moved there (src/metro/metro-transformer.ts), and it still merges cleanly onto current main. Every measurement above was taken on f70c402. Say the word and I will re-apply it onto current main.

…r its web build

The transformer asked Expo's worker for the web transform of every native
stylesheet and compiled that. Expo's web transform runs lightningcss against
the project's browserslist, so the native compiler received the sheet as
lowered for browsers: every `:dir()` a `:lang()` list the compiler drops
(nativewind#453), every `oklch()` a `lab()` beside a hex-fallback `:root` that doubles
each root variable and defeats variable inlining, and all of it moving with
the browserslist.

Run the project's PostCSS and Sass through Expo's own modules and compile
their output. No browser build is produced for a native stylesheet.

Closes nativewind#453
@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Evidence

Measured on a real Tailwind v4 entry, through Expo's actual web transform:

the lowering measured effect on native
:dir(rtl):is(:lang(ae), :lang(ar), …) 15 rtl: / ltr: rules dropped
oklch()lab(), plus a hex-fallback :root beside a @supports copy every root variable declared twice, so inlineVariables inlines none and 331 colour utilities compile to a runtime var() lookup
transform-origin: left top 30px re-serialised ["0%", 30, 0] instead of [0, 0, 30]

All of it moves with the project's browserslist: once a window rolls past Chrome 119 those rtl: classes start working on native with no change to anything.

The test drives the real transform against a stand-in for Expo's worker whose web branch does what Expo's does — the project's own PostCSS, then lightningcss targeted at chrome: 100, so the lowering happens for real rather than being imitated. Two of the eight cases are red on main; the other six pin what must not move, and a second fixture project with no PostCSS config pins the plain path.

yarn jest src/__tests__/metro 10 passed · yarn typecheck 0 · yarn lint 0

Also measured and deliberately unchanged: a stylesheet Expo's web pass would have recovered from with errorRecovery compiles to the same surviving rules, so a device does not become stricter about broken CSS than a browser was.

No device frame of its own — this defect and #459's paint the same picture, an rtl: utility that does not apply, so one frame cannot attribute itself to either. #459 carries the pair.

Both fixture projects authored plain CSS, so `matchSass` answered null on every
case and `compileSass` was never reached. Measured: deleting the Sass call
outright left all 10 tests green, so a whole arm of the new function was
unobservable.

A third project authors `.scss` — a `$variable` and a nested `&.nested`, neither
of which is CSS — and stands in for Expo's Sass helper, which resolves the
optional `sass` package from the project root and throws when it is absent.
Three cases: the syntax and filename the transformer hands over, that the
compiler receives what Sass produced rather than the authored source, and that
the stylesheet's own output is still emptied.

Its own file, for the reason the plain project has one: Expo resolves a
project's pipeline once per process.

The same deletion now reddens all three.

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.

rtl:/ltr: variants silently dropped — lightningcss downcompiles :dir() to :lang() which is unhandled

1 participant