From a35d09c719c6a5aafa6b67f01d8c5a9406ae74d9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 14:11:28 +0300 Subject: [PATCH 1/2] fix(metro): compile the native stylesheet from the authored CSS, never 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 (#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 #453 --- src/__tests__/metro/_expo-worker.ts | 83 +++++++++ src/__tests__/metro/_project-plain/global.css | 2 + src/__tests__/metro/_project/global.css | 6 + .../metro/_project/postcss.config.json | 5 + .../metro/native-stylesheet-input.test.ts | 165 ++++++++++++++++++ .../native-stylesheet-plain-project.test.ts | 77 ++++++++ src/metro/metro-transformer.ts | 55 +++++- 7 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/metro/_expo-worker.ts create mode 100644 src/__tests__/metro/_project-plain/global.css create mode 100644 src/__tests__/metro/_project/global.css create mode 100644 src/__tests__/metro/_project/postcss.config.json create mode 100644 src/__tests__/metro/native-stylesheet-input.test.ts create mode 100644 src/__tests__/metro/native-stylesheet-plain-project.test.ts diff --git a/src/__tests__/metro/_expo-worker.ts b/src/__tests__/metro/_expo-worker.ts new file mode 100644 index 00000000..574787df --- /dev/null +++ b/src/__tests__/metro/_expo-worker.ts @@ -0,0 +1,83 @@ +/** + * A stand-in for Expo's transform worker, so the Metro transformer can be driven without Metro. + * + * Its web branch does what `@expo/metro-config`'s `transformCss` does to a global stylesheet: the + * project's PostCSS, then lightningcss against the project's browser targets. The target here is + * one browser without `:dir()` support, so the lowering that pass performs for real (a `:dir()` + * becomes a `:lang()` list) happens here for real too. Every other file is echoed back as a bare + * JS module. + */ +import { transformPostCssModule } from "@expo/metro-config/build/transform-worker/postcss"; +import { Features, transform as transformStylesheet } from "lightningcss"; +import type { + JsTransformerConfig, + JsTransformOptions, + TransformResponse, +} from "metro-transform-worker"; + +export interface RecordedTransform { + readonly filePath: string; + readonly platform: string | undefined; + readonly source: string; +} + +export const recordedTransforms: RecordedTransform[] = []; + +/** Chrome 100 predates `:dir()` (Chrome 120), in lightningcss's `major << 16` encoding. */ +const CHROME_100 = 100 << 16; + +const jsModule = (code: string): TransformResponse => ({ + dependencies: [], + output: [ + { + type: "js/module", + data: { + code, + lineCount: code.split("\n").length, + map: [], + functionMap: null, + }, + }, + ], +}); + +export async function transform( + _config: JsTransformerConfig, + projectRoot: string, + filePath: string, + data: Buffer, + options: JsTransformOptions, +): Promise { + recordedTransforms.push({ + filePath, + platform: options.platform, + source: data.toString(), + }); + + if (options.platform === "web" && filePath.endsWith(".css")) { + const { src } = await transformPostCssModule(projectRoot, { + src: data.toString(), + filename: filePath, + }); + const { code } = transformStylesheet({ + filename: filePath, + code: Buffer.from(src), + targets: { chrome: CHROME_100 }, + include: Features.Nesting, + errorRecovery: true, + }); + const browserBuild: TransformResponse["output"][number] = { + type: "js/module", + data: { + code: "", + lineCount: 0, + map: [], + functionMap: null, + css: { code: Buffer.from(code) }, + }, + }; + return { dependencies: [], output: [browserBuild] }; + } + + return jsModule(data.toString()); +} diff --git a/src/__tests__/metro/_project-plain/global.css b/src/__tests__/metro/_project-plain/global.css new file mode 100644 index 00000000..1470b5c2 --- /dev/null +++ b/src/__tests__/metro/_project-plain/global.css @@ -0,0 +1,2 @@ +.plain { color: red; } +.plain:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: blue; } diff --git a/src/__tests__/metro/_project/global.css b/src/__tests__/metro/_project/global.css new file mode 100644 index 00000000..d2c29056 --- /dev/null +++ b/src/__tests__/metro/_project/global.css @@ -0,0 +1,6 @@ +@import "tailwindcss/utilities.css"; +@theme { + --color-brand: oklch(62.3% 0.214 259.815); +} +@source not "**/*"; +@source inline("bg-[#00f] rtl:bg-[#f00] bg-brand"); diff --git a/src/__tests__/metro/_project/postcss.config.json b/src/__tests__/metro/_project/postcss.config.json new file mode 100644 index 00000000..e092dc7c --- /dev/null +++ b/src/__tests__/metro/_project/postcss.config.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/src/__tests__/metro/native-stylesheet-input.test.ts b/src/__tests__/metro/native-stylesheet-input.test.ts new file mode 100644 index 00000000..c3edae05 --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-input.test.ts @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, + TransformResponse, +} from "metro-transform-worker"; +import type { ReactNativeCssStyleSheet } from "react-native-css/compiler"; + +import { transform } from "../../metro/metro-transformer"; +import { recordedTransforms } from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +const PROJECT_ROOT = path.join(__dirname, "_project"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.css"); + +// The transformer forwards the config to the worker untouched and reads nothing else from it. +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + hot: false, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "android", + type: "module", + unstable_transformProfile: "default", +}; + +function injectedStylesheet(): ReactNativeCssStyleSheet { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + ); + if (!injection) { + throw new Error( + "the transformer never handed the worker an injection module", + ); + } + const match = /StyleCollection\.inject\((.*)\);/s.exec(injection.source); + if (!match?.[1]) { + throw new Error("the injection module carries no stylesheet"); + } + return JSON.parse(match[1]) as ReactNativeCssStyleSheet; +} + +function rulesFor( + stylesheet: ReactNativeCssStyleSheet, + className: string, +): string | undefined { + const entry = stylesheet.s?.find(([name]) => name === className); + return entry ? JSON.stringify(entry[1]) : undefined; +} + +/** The response with the CSS metadata the transformer attaches — the shape it declares itself. */ +type StylesheetTransformResponse = TransformResponse & { + output: [{ data: { css: unknown } }]; +}; + +let response: StylesheetTransformResponse; + +beforeAll(async () => { + response = (await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + )) as StylesheetTransformResponse; +}); + +test("the native stylesheet is what the project's PostCSS produced", () => { + // Only Tailwind emits this class; the authored stylesheet merely names it. + expect(rulesFor(injectedStylesheet(), "bg-[#00f]")).toContain( + '"backgroundColor":"#00f"', + ); +}); + +test("a theme colour declared once is inlined, because no browser build doubled its declaration", () => { + const brandRule = rulesFor(injectedStylesheet(), "bg-brand"); + + expect(brandRule).toContain('"backgroundColor":"#'); + expect(brandRule).not.toContain('"var"'); +}); + +test("the stylesheet's own output is emptied and marked uncacheable, since PostCSS reads other files", () => { + expect(response.output[0].data.css).toStrictEqual({ + skipCache: true, + code: "", + }); +}); + +test("the native stylesheet is compiled from the authored CSS, never from a browser build of it", () => { + const rtlRule = rulesFor(injectedStylesheet(), "rtl:bg-[#f00]"); + + expect(rtlRule).toContain('"dir"'); + expect(rtlRule).toContain('"rtl"'); + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); +}); + +test("the injection module replaces the stylesheet as the module's code", () => { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + ); + + expect(injection?.platform).toBe("android"); + expect(injection?.source).toContain( + 'import { StyleCollection } from "react-native-css/native-internal";', + ); +}); + +test("a web build of the stylesheet is the worker's, untouched", async () => { + const source = readFileSync(STYLESHEET_PATH); + const webResponse = (await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + source, + { + ...NATIVE_OPTIONS, + platform: "web", + }, + )) as StylesheetTransformResponse; + + const webTransform = recordedTransforms.find( + ({ filePath, platform }) => + filePath === STYLESHEET_PATH && platform === "web", + ); + expect(webTransform?.source).toBe(source.toString()); + expect(webResponse.output[0].data.css).toBeDefined(); +}); + +test("a module that is not a stylesheet reaches the worker untouched", async () => { + const modulePath = path.join(PROJECT_ROOT, "module.js"); + const source = Buffer.from("export const untouched = 1;\n"); + + await transform(CONFIG, PROJECT_ROOT, modulePath, source, NATIVE_OPTIONS); + + const moduleTransform = recordedTransforms.find( + ({ filePath }) => filePath === modulePath, + ); + expect(moduleTransform?.platform).toBe("android"); + expect(moduleTransform?.source).toBe(source.toString()); +}); + +test("a stylesheet requested as an asset reaches the worker untouched", async () => { + const assetPath = path.join(PROJECT_ROOT, "asset.css"); + const source = Buffer.from(".asset { color: red; }\n"); + + await transform(CONFIG, PROJECT_ROOT, assetPath, source, { + ...NATIVE_OPTIONS, + type: "asset", + }); + + const assetTransform = recordedTransforms.find( + ({ filePath }) => filePath === assetPath, + ); + expect(assetTransform?.source).toBe(source.toString()); +}); diff --git a/src/__tests__/metro/native-stylesheet-plain-project.test.ts b/src/__tests__/metro/native-stylesheet-plain-project.test.ts new file mode 100644 index 00000000..d0c5bb64 --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-plain-project.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, +} from "metro-transform-worker"; +import type { ReactNativeCssStyleSheet } from "react-native-css/compiler"; + +import { transform } from "../../metro/metro-transformer"; +import { recordedTransforms } from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +// Its own file: Expo resolves a project's PostCSS pipeline once per process, so a project WITHOUT a +// config has to be the first one this process sees. +const PROJECT_ROOT = path.join(__dirname, "_project-plain"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.css"); + +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + hot: false, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "ios", + type: "module", + unstable_transformProfile: "default", +}; + +function injectedStylesheet(): ReactNativeCssStyleSheet { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + ); + if (!injection) { + throw new Error( + "the transformer never handed the worker an injection module", + ); + } + const match = /StyleCollection\.inject\((.*)\);/s.exec(injection.source); + if (!match?.[1]) { + throw new Error("the injection module carries no stylesheet"); + } + return JSON.parse(match[1]) as ReactNativeCssStyleSheet; +} + +beforeAll(async () => { + await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + ); +}); + +test("a project with no PostCSS config compiles the stylesheet as authored", () => { + const rules = injectedStylesheet().s?.find(([name]) => name === "plain")?.[1]; + + expect(rules).toHaveLength(2); + expect(JSON.stringify(rules)).toContain('"dir"'); + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); +}); + +test("the injection module is transformed for the platform that asked", () => { + expect( + recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + )?.platform, + ).toBe("ios"); +}); diff --git a/src/metro/metro-transformer.ts b/src/metro/metro-transformer.ts index e56e1ca7..70bb4ea6 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -1,4 +1,6 @@ import { unstable_transformerPath } from "@expo/metro-config"; +import { transformPostCssModule } from "@expo/metro-config/build/transform-worker/postcss"; +import * as sassPreprocessor from "@expo/metro-config/build/transform-worker/sass"; import type { JsTransformerConfig, JsTransformOptions, @@ -12,6 +14,46 @@ const worker = // eslint-disable-next-line @typescript-eslint/no-require-imports require(unstable_transformerPath) as typeof import("metro-transform-worker"); +/** Expo types its Sass helpers through the optional `sass` package, so the shape used here is stated here. */ +interface SassPreprocessor { + readonly matchSass: (filename: string) => string | null; + readonly compileSass: ( + projectRoot: string, + input: { filename: string; src: string }, + options: { syntax: string }, + ) => { src: string }; +} + +const { compileSass, matchSass }: SassPreprocessor = sassPreprocessor; + +/** + * The stylesheet as the project authored it, after its PostCSS and Sass steps. + * + * This is the input the native compiler needs, and it is NOT the web build of the same file. + * Expo's web transform also runs lightningcss against the project's browserslist, and a device + * is not one of those browsers: that pass lowers `:dir()` into `:lang()` lists the compiler has + * no reading for, so every `rtl:` / `ltr:` rule is dropped; it rewrites `oklch()` colors as + * `lab()` beside a hex fallback in a second `:root`, which doubles every root variable and stops + * the compiler inlining any of them; and all of it moves whenever the browserslist does. + */ +async function preprocessStylesheet( + projectRoot: string, + filePath: string, + source: string, +): Promise { + const { src } = await transformPostCssModule(projectRoot, { + src: source, + filename: filePath, + }); + + const syntax = matchSass(filePath); + if (!syntax) { + return src; + } + + return compileSass(projectRoot, { filename: filePath, src }, { syntax }).src; +} + export async function transform( config: JsTransformerConfig & { reactNativeCSS?: CompilerOptions | undefined; @@ -27,14 +69,11 @@ export async function transform( return worker.transform(config, projectRoot, filePath, data, options); } - 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(); + const css = await preprocessStylesheet( + projectRoot, + filePath, + data.toString("utf8"), + ); const productionJS = compile(css, { ...config.reactNativeCSS, From 3337c2dfe6f0faadfbecbfe40303ff36d8feee79 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 18:41:37 +0300 Subject: [PATCH 2/2] test(metro): drive the Sass arm of the native stylesheet input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/metro/_project-sass/global.scss | 9 ++ .../metro/native-stylesheet-sass.test.ts | 109 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/__tests__/metro/_project-sass/global.scss create mode 100644 src/__tests__/metro/native-stylesheet-sass.test.ts diff --git a/src/__tests__/metro/_project-sass/global.scss b/src/__tests__/metro/_project-sass/global.scss new file mode 100644 index 00000000..6c24bd98 --- /dev/null +++ b/src/__tests__/metro/_project-sass/global.scss @@ -0,0 +1,9 @@ +$brand: #f00; + +.sassy { + color: $brand; + + &.nested { + color: #00f; + } +} diff --git a/src/__tests__/metro/native-stylesheet-sass.test.ts b/src/__tests__/metro/native-stylesheet-sass.test.ts new file mode 100644 index 00000000..133f59fb --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-sass.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, +} from "metro-transform-worker"; +import type { ReactNativeCssStyleSheet } from "react-native-css/compiler"; + +import { transform } from "../../metro/metro-transformer"; +import { recordedTransforms } from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +interface SassCall { + readonly filename: string; + readonly src: string; + readonly syntax: string; +} + +const sassCalls: SassCall[] = []; + +// Expo resolves `sass` from the PROJECT root and throws when it is absent, so the real compiler is +// unreachable from this repo's dev dependencies. Standing in for it keeps the arm drivable and +// asserts the hand-off — which syntax the transformer names, and that the Sass output is what +// reaches the compiler rather than the authored source. +jest.mock("@expo/metro-config/build/transform-worker/sass", () => ({ + matchSass: (filename: string): string | null => + filename.endsWith(".scss") ? "scss" : null, + compileSass: ( + _projectRoot: string, + input: { filename: string; src: string }, + options: { syntax: string }, + ): { src: string } => { + sassCalls.push({ ...input, syntax: options.syntax }); + return { src: ".sassy { color: #f00; }\n.sassy.nested { color: #00f; }" }; + }, +})); + +const PROJECT_ROOT = path.join(__dirname, "_project-sass"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.scss"); + +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + hot: false, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "android", + type: "module", + unstable_transformProfile: "default", +}; + +function injectedStylesheet(): ReactNativeCssStyleSheet { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + ); + if (!injection) { + throw new Error( + "the transformer never handed the worker an injection module", + ); + } + const match = /StyleCollection\.inject\((.*)\);/s.exec(injection.source); + if (!match?.[1]) { + throw new Error("the injection module carries no stylesheet"); + } + return JSON.parse(match[1]) as ReactNativeCssStyleSheet; +} + +beforeAll(async () => { + await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + ); +}); + +test("a .scss entry is compiled by Sass before the native compiler sees it", () => { + expect(sassCalls).toHaveLength(1); + expect(sassCalls[0]?.syntax).toBe("scss"); + expect(sassCalls[0]?.filename).toBe(STYLESHEET_PATH); +}); + +test("what Sass produced is the input, not the authored source", () => { + // The authored file nests `&.nested` inside `.sassy` and names a `$brand` variable, neither of + // which is CSS — so a rule for the nested class exists only if the Sass output was compiled. + const names = injectedStylesheet().s?.map(([name]) => name) ?? []; + + expect(names).toContain("sassy"); + expect(names).toContain("nested"); + expect(sassCalls[0]?.src).toContain("$brand"); +}); + +test("the stylesheet's own output is emptied and the injection carries the platform", () => { + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); + expect( + recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + )?.platform, + ).toBe("android"); +});