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-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/_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/__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"); +}); 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,