Skip to content

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

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

YevheniiKotyrlo wants to merge 1 commit 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. That build is @expo/metro-config's transformCss: PostCSS, Sass, then lightningcss against the project's browserslist, or browserslist's defaults when the project declares none. So the native compiler receives CSS lowered for browsers. Measured on a Tailwind v4 entry:

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

All of it moves with the browserslist.

Solution

The transformer runs the project's PostCSS and Sass through the modules Expo's web transform calls, in the same order, and compiles their output. No browser build is produced for a native stylesheet.

  • transformPostCssModule resolves and caches the project's postcss.config.* as the web transform does, so a plugin runs for a device exactly when it runs for a browser.
  • The web transform's lightningcss pass also collected @import dependencies, but the transformer already discarded them.

compile()'s own lightningcss pass sets no targets and keeps :dir(); it was handed the wrong input.

Both helpers are imported from @expo/metro-config/build/transform-worker/, which is not listed as public API.

Tests

  • src/__tests__/metro/native-stylesheet-input.test.ts drives the real transform against a stand-in for Expo's worker.
    • The stand-in's web branch runs the project's PostCSS, then lightningcss against chrome: 100, which predates :dir(), so the lowering happens for real.
    • The project is a postcss.config.json naming @tailwindcss/postcss, a @theme colour in oklch(), and bg-[#00f] rtl:bg-[#f00] bg-brand.
    • Two cases are the bug's shape: rtl:bg-[#f00] keeps its dir condition while the worker is never asked for a web build, and bg-brand inlines to a literal.
    • Six pin what must not move: the injected stylesheet, the emptied and skipCached output, and three requests that must reach the worker untouched (a web build, a non-stylesheet module, a stylesheet requested as an asset).
  • src/__tests__/metro/native-stylesheet-plain-project.test.ts is a project with no PostCSS config, in its own file because Expo resolves a project's pipeline once per process.
  • src/__tests__/metro/native-stylesheet-sass.test.ts authors .scss. It stands in for Expo's Sass helper, which resolves the optional sass package from the project root, and for the project's PostCSS. It asserts the hand-off:
    • which syntax is named
    • that Sass receives PostCSS's output
    • that the compiler receives what Sass produced

On main, 7 of the 14 cases fail; all pass here. Three mutations each fail their own cases: skipping PostCSS, skipping Sass, and handing Sass the authored source.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --maxWorkers=2 --coverage: 1345 passed, 4 failed. The 4 are the babel cases that also fail on main on this machine.
  • yarn build clean
  • yarn example expo export --platform web exported
  • nothing unstaged after either build

I also measured broken CSS and changed nothing for it. A malformed selector, a stray brace or an unknown at-rule compiles to the same surviving rules Expo's web pass recovered with errorRecovery, so a device is not stricter about broken CSS than a browser.

Device evidence, before and after: #461 (comment)

Related

Closes #453. That report places the lowering inside compile(); it happens one hop earlier.

Base

Re-written on main (a5002c5). #451 moved Metro to 0.84, which no longer exports TransformResponse and drops hot from the transform options, so the tests derive the response from transform's own return type.

@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The light-dark() bar is an empty outline. The compiler receives CSS lowered for browsers, where light-dark() is a pair of variables React Native discards.

FIXED — The light-dark() bar paints its light arm, red, like the control. The compiler receives light-dark() as authored.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

…r its web build

The transformer compiled Expo's web build of the stylesheet, which
lightningcss had lowered for the project's browserslist: `:dir()` became
`:lang()` lists the compiler cannot read, `oklch()` gained a second
`:root`, and the native output moved with the browserslist. It now runs
the project's PostCSS and Sass, in the order Expo's web transform runs
them, and compiles their output.

Closes nativewind#453

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