Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/__tests__/metro/_expo-worker.ts
Original file line number Diff line number Diff line change
@@ -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<TransformResponse> {
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());
}
2 changes: 2 additions & 0 deletions src/__tests__/metro/_project-plain/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.plain { color: red; }
.plain:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: blue; }
9 changes: 9 additions & 0 deletions src/__tests__/metro/_project-sass/global.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
$brand: #f00;

.sassy {
color: $brand;

&.nested {
color: #00f;
}
}
6 changes: 6 additions & 0 deletions src/__tests__/metro/_project/global.css
Original file line number Diff line number Diff line change
@@ -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");
5 changes: 5 additions & 0 deletions src/__tests__/metro/_project/postcss.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
165 changes: 165 additions & 0 deletions src/__tests__/metro/native-stylesheet-input.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
77 changes: 77 additions & 0 deletions src/__tests__/metro/native-stylesheet-plain-project.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading