diff --git a/README.md b/README.md index bafb2302..36992f9b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,28 @@ This API only allows for setting CSS variables as primitive values. For more com > [!IMPORTANT] > By using `VariableContext` you may need to disable the `inlineVariable` optimization +## Compiler warnings + +Not every CSS declaration has a React Native equivalent. When the compiler cannot translate one it drops that declaration, and Metro prints a summary for the stylesheet it came from: + +``` +react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent + properties: columns, float + values: z-index: auto +``` + +A block is printed the first time a file is compiled, and after that only when the file's set of warnings changes — an incremental rebuild that changes nothing about them stays quiet. Warnings are advisory and never fail a build. + +Use the `warnings` option to change how much is printed: + +```tsx +export default withReactNativeCSS(defaultConfig, { + warnings: "verbose", // "summary" (the default) | "verbose" | "none" +}); +``` + +`summary` lists the first ten entries of each channel, `verbose` lists every entry, and `none` prints nothing. A web bundle never runs the compiler, so this only affects native builds. + ## Optimizations CSS is a dynamic styling language that use highly optimized engines that are not available in React Native. Instead, we optimize the styles to improve performance diff --git a/src/__tests__/_transform.ts b/src/__tests__/_transform.ts new file mode 100644 index 00000000..add8e492 --- /dev/null +++ b/src/__tests__/_transform.ts @@ -0,0 +1,32 @@ +import { transformSync } from "@babel/core"; + +import plugin from "../babel/import-plugin"; + +/** + * Drives the real babel plugin through `@babel/core`, so `state.filename` is + * populated by babel itself rather than by a test double. `configFile` / + * `babelrc` are off so the result is this plugin's output and nothing else. + * + * Underscore-prefixed, so jest's `testPathIgnorePatterns` treats it as a fixture + * rather than a suite. + */ +export function transformWithBabelPlugin( + code: string, + filename?: string, + options: { cwd?: string } = {}, +): string { + const result = transformSync(code, { + filename, + cwd: options.cwd, + configFile: false, + babelrc: false, + plugins: [plugin], + }); + + const output = result?.code; + if (typeof output !== "string") { + throw new Error(`babel produced no output for ${filename ?? ""}`); + } + + return output; +} diff --git a/src/__tests__/babel/helpers.test.ts b/src/__tests__/babel/helpers.test.ts new file mode 100644 index 00000000..2b2e47d4 --- /dev/null +++ b/src/__tests__/babel/helpers.test.ts @@ -0,0 +1,220 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join, resolve, sep } from "path"; + +import { + findPackageRoot, + resolveImportSource, + toPosixPath, +} from "../../babel/helpers"; + +/** + * `path.resolve` prepends the cwd's drive on Windows and nothing on POSIX. The + * drive is not part of what any case here pins, so it is dropped before + * comparing and one literal expectation serves both hosts. + */ +function withoutDrive(path: string): string { + return path.replace(/^[A-Za-z]:/, ""); +} + +const WINDOWS_SEPARATOR = "\\"; +const POSIX_SEPARATOR = "/"; + +describe("toPosixPath", () => { + // Every case supplies the host separator, so both branches are exercised on + // any host — including ubuntu-latest, the only platform CI runs. Inputs are + // Windows-shaped literals rather than `path.resolve` output for the same + // reason: `resolve()` on Linux never emits a backslash, so a table driven + // through it would assert nothing there. + const cases: { + name: string; + input: string; + hostSeparator: string; + expected: string; + }[] = [ + { + name: "an absolute Windows path", + input: "C:\\project\\node_modules\\react-native-web\\dist\\exports\\View", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "an already-POSIX path, unchanged", + input: "/project/node_modules/react-native-web/dist/exports/View", + hostSeparator: POSIX_SEPARATOR, + expected: "/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "mixed separators, every one of them", + input: "C:/project\\node_modules/react-native\\Libraries", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/node_modules/react-native/Libraries", + }, + { + name: "a UNC-style prefix, both leading separators", + input: "\\\\build-server\\share\\project\\index.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "//build-server/share/project/index.js", + }, + { + name: "a POSIX filename whose own name contains a backslash, left intact", + // The gate's reason to exist: on POSIX this is one file called + // `weird\name.js`, and splitting it would name a path that does not exist. + input: "/project/weird\\name.js", + hostSeparator: POSIX_SEPARATOR, + expected: "/project/weird\\name.js", + }, + { + name: "the same characters on a Windows host, split into segments", + // Same input, opposite verdict — so what decides is the host separator, + // not anything about the string. + input: "/project/weird\\name.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "/project/weird/name.js", + }, + { + name: "a relative path", + input: "..\\View\\View.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "../View/View.js", + }, + { + name: "a trailing separator, preserved as a POSIX one", + input: "C:\\project\\dist\\", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/dist/", + }, + { + name: "the empty string on a Windows host", + input: "", + hostSeparator: WINDOWS_SEPARATOR, + expected: "", + }, + { + name: "the empty string on a POSIX host", + input: "", + hostSeparator: POSIX_SEPARATOR, + expected: "", + }, + ]; + + test.each(cases)("$name", ({ input, hostSeparator, expected }) => { + expect(toPosixPath(input, hostSeparator)).toBe(expected); + }); + + test("makes the marker the import handlers split on findable", () => { + // The defect itself: the resolved path plainly contains those directories, + // and the forward-slash marker is absent from it until this runs. + const resolved = + "C:\\project\\node_modules\\react-native\\Libraries\\Components\\View\\View"; + const marker = "react-native/Libraries/Components/"; + const posix = toPosixPath(resolved, WINDOWS_SEPARATOR); + + expect(resolved).not.toContain(marker); + expect(posix).toContain(marker); + expect(posix.split(marker)[1]).toBe("View/View"); + }); +}); + +describe("resolveImportSource", () => { + // The base is `dirname(filename)`, and every case below observes that by + // counting `..` segments — falsifiable on any host. + const filename = + "/project/node_modules/react-native-web/dist/exports/View/index.js"; + + const cases: { name: string; source: string; expected: string }[] = [ + { + name: "a sibling of the file", + source: "./types", + expected: + "/project/node_modules/react-native-web/dist/exports/View/types", + }, + { + name: "a sibling of the file's directory", + source: "../Text", + expected: "/project/node_modules/react-native-web/dist/exports/Text", + }, + { + name: "the file's own directory", + source: ".", + expected: "/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "the parent of the file's directory", + source: "..", + expected: "/project/node_modules/react-native-web/dist/exports", + }, + { + name: "a climb that leaves the package's dist directory", + source: "../../../View", + expected: "/project/node_modules/react-native-web/View", + }, + ]; + + test.each(cases)("$name", ({ source, expected }) => { + expect(withoutDrive(resolveImportSource(filename, source))).toBe(expected); + }); + + test("hands path.resolve's output to the host's normalization", () => { + // Pins the composition: `resolve` over the file's directory, then + // `toPosixPath` with the real `path.sep`. On POSIX the normalization is the + // identity and this reduces to `resolve` — which is the one thing here that + // cannot fail on ubuntu-latest, the only platform CI runs. The branch it + // reduces away is held instead by the `toPosixPath` table above, which + // supplies the separator and so needs no Windows host. + const source = "../Text"; + + expect(resolveImportSource(filename, source)).toBe( + toPosixPath(resolve(dirname(filename), source), sep), + ); + }); +}); + +describe("findPackageRoot", () => { + // Built into a temporary directory rather than asserted against this + // repository, because the wrinkle under test only exists in the BUILT layout: + // react-native-builder-bob writes a bare `{ "type": … }` package.json into + // each output directory (`react-native-builder-bob/lib/src/utils/compile.js`), + // and stopping at one of those names `/dist/commonjs` as the package. + // Running from source, the walk never meets one. + let root = ""; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "react-native-css-root-")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function write(relativePath: string, contents: string): void { + const target = join(root, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } + + test("walks past a manifest that only declares a module type", () => { + write("package.json", JSON.stringify({ name: "react-native-css" })); + write("dist/commonjs/package.json", JSON.stringify({ type: "commonjs" })); + mkdirSync(join(root, "dist", "commonjs", "babel"), { recursive: true }); + + expect(findPackageRoot(join(root, "dist", "commonjs", "babel"))).toBe(root); + }); + + test("finds the root from the source layout too", () => { + write("package.json", JSON.stringify({ name: "react-native-css" })); + mkdirSync(join(root, "src", "babel"), { recursive: true }); + + expect(findPackageRoot(join(root, "src", "babel"))).toBe(root); + }); + + test("stops at the nearest named manifest, not the outermost", () => { + write("package.json", JSON.stringify({ name: "outer" })); + write("packages/inner/package.json", JSON.stringify({ name: "inner" })); + mkdirSync(join(root, "packages", "inner", "src"), { recursive: true }); + + expect(findPackageRoot(join(root, "packages", "inner", "src"))).toBe( + join(root, "packages", "inner"), + ); + }); +}); diff --git a/src/__tests__/babel/import-plugin.test.ts b/src/__tests__/babel/import-plugin.test.ts new file mode 100644 index 00000000..233e1b0a --- /dev/null +++ b/src/__tests__/babel/import-plugin.test.ts @@ -0,0 +1,148 @@ +import { transformWithBabelPlugin as transform } from "../_transform"; + +// Absolute POSIX filenames. `path.resolve` prepends the cwd's drive letter on +// Windows, which changes the prefix of the resolved path but not the two things +// the handlers read from it — whether the package marker is present, and the +// last segment — so every expectation below holds on either host. +const REACT_NATIVE_WEB_DIST = "/project/node_modules/react-native-web/dist"; +const REACT_NATIVE_LIBRARIES = "/project/node_modules/react-native/Libraries"; + +describe("relative imports resolve against the file's directory", () => { + // `state.filename` is babel's path of the FILE being transformed + // (`PluginPass.filename` is `file.opts.filename`, and babel derives + // `sourceFileName` from `basename(filenameRelative)`), so a relative source + // resolves against `dirname(filename)`. Resolving against the filename itself + // consumes one `..` too few, which moves the package boundary by one directory. + + test("react-native-web: an import that leaves dist is not a dist internal", () => { + // `../../../View` from `dist/exports/View/index.js` lands on + // `react-native-web/View` — outside `dist`, so not a component this plugin + // owns. Resolved against the filename it lands on `dist/View` instead, and + // the plugin swaps a module the author never asked for. + const code = transform( + `import View from "../../../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/index.js`, + ); + + expect(code).toBe(`import View from "../../../View";`); + }); + + test("react-native-web: a sibling importing its own directory index is rewritten", () => { + // `.` from `dist/exports/View/types.js` is the directory `dist/exports/View`, + // whose index IS react-native-web's View. Resolved against the filename it is + // `types.js`, which is in no component census, so the rewrite is missed. + const code = transform( + `import View from ".";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/types.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("react-native: an import that leaves Libraries/Components is not a component", () => { + // `../../View` from `Libraries/Components/View/View.js` lands on + // `react-native/Libraries/View`, which is not under `Components/`. + const code = transform( + `import View from "../../View";`, + `${REACT_NATIVE_LIBRARIES}/Components/View/View.js`, + ); + + expect(code).toBe(`import View from "../../View";`); + }); + + test("both handlers place the package boundary at the same depth", () => { + // The two handlers resolve the same way or they do not; this pins that they + // do, at the one depth where an off-by-one base is observable. Each source + // climbs exactly out of its package's marker directory. + const web = transform( + `import View from "../../../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/index.js`, + ); + const native = transform( + `import View from "../../View";`, + `${REACT_NATIVE_LIBRARIES}/Components/View/View.js`, + ); + + expect(web).toBe(`import View from "../../../View";`); + expect(native).toBe(`import View from "../../View";`); + }); +}); + +describe("relative imports inside a package are rewritten", () => { + test("react-native-web: a sibling component", () => { + const code = transform( + `import View from "../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/ScrollView/index.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("react-native-web: a require() of a sibling component", () => { + const code = transform( + `const View = _interopRequireDefault(require("../View"));`, + `${REACT_NATIVE_WEB_DIST}/exports/ScrollView/index.js`, + ); + + expect(code).toBe( + `const {\n View\n} = require("react-native-css/components/View");`, + ); + }); + + test("react-native: a sibling component", () => { + const code = transform( + `import View from "../View/View";`, + `${REACT_NATIVE_LIBRARIES}/Components/ScrollView/ScrollView.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("a module outside either package is left alone", () => { + const code = transform( + `import View from "../View";`, + `/project/src/screens/Home.js`, + ); + + expect(code).toBe(`import View from "../View";`); + }); +}); + +describe("package-level specifiers", () => { + const APP_FILE = "/project/src/screens/Home.js"; + + test("react-native-web: only the specifiers with a component are moved", () => { + const code = transform( + `import { View, Text, StyleSheet, Dimensions } from "react-native-web";`, + APP_FILE, + ); + + expect(code).toBe( + [ + `import { View } from "react-native-css/components/View";`, + `import { Text } from "react-native-css/components/Text";`, + `import { StyleSheet } from "react-native-web";`, + `import { Dimensions } from "react-native-web";`, + ].join("\n"), + ); + }); + + test("react-native: a deep path outside Libraries/Components still names its component", () => { + // Not a relative source, so no resolution happens — the last segment of the + // specifier is the component name. + const code = transform( + `import { View } from "react-native/lib/components/View";`, + APP_FILE, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); +}); diff --git a/src/__tests__/babel/own-sources.test.ts b/src/__tests__/babel/own-sources.test.ts new file mode 100644 index 00000000..cca78636 --- /dev/null +++ b/src/__tests__/babel/own-sources.test.ts @@ -0,0 +1,131 @@ +import { join, resolve } from "path"; + +import { transformSync, type PluginObj } from "@babel/core"; + +import { findPackageRoot } from "../../babel/helpers"; +import { transformWithBabelPlugin as transform } from "../_transform"; + +/** + * The plugin must never rewrite this package's own components. They import the + * primitive they wrap — `src/components/View.tsx` opens with + * `import { View as RNView } from "react-native"` — so a rewrite turns each of + * them into an import of itself. + * + * Metro decides which files those are through two values it supplies to babel + * (`metro/src/DeltaBundler/Transformer.js` hands the worker + * `path.relative(projectRoot, filePath)`, and `metro-babel-transformer` sets + * `cwd: options.projectRoot`): the filename is PROJECT-RELATIVE and the cwd is + * the project root. Comparing the relative name against an absolute prefix + * matches nothing, whatever the host. + */ +describe("this package's own sources", () => { + const packageRoot = findPackageRoot(__dirname); + + test("the package root is this repository", () => { + // Derived independently of the walk under test: this file sits at + // /src/__tests__/babel/. + expect(packageRoot).toBe(resolve(__dirname, "..", "..", "..")); + }); + + test("babel hands a plugin an absolute filename, whatever it was given", () => { + // The premise the guard rests on. `@babel/core/lib/config/partial.js` stores + // `path.resolve(cwd, opts.filename)`, so metro's project-relative name is + // already absolute by the time a visitor runs and the guard needs no + // resolution of its own. Should that ever change, this fails and says so. + let seen: string | undefined = undefined; + const capture = (): PluginObj => ({ + name: "capture-filename", + visitor: { + Program(_path, state) { + seen = state.filename; + }, + }, + }); + + transformSync("", { + filename: join("src", "components", "View.tsx"), + cwd: packageRoot, + configFile: false, + babelrc: false, + plugins: [capture], + }); + + expect(seen).toBe(join(packageRoot, "src", "components", "View.tsx")); + }); + + test("a component of this package keeps its react-native import", () => { + const code = transform( + `import { View as RNView } from "react-native";`, + join("src", "components", "View.tsx"), + { cwd: packageRoot }, + ); + + expect(code).toBe(`import { View as RNView } from "react-native";`); + }); + + test("a built component of this package keeps its react-native import", () => { + // What a consumer's metro actually transforms: this package under their + // node_modules, named relative to their project root. + const consumerProjectRoot = resolve(packageRoot, "..", ".."); + const relativeToConsumer = join( + ...packageRoot.slice(consumerProjectRoot.length + 1).split(/[\\/]/), + "dist", + "commonjs", + "components", + "View.js", + ); + + const code = transform( + `import { View as RNView } from "react-native";`, + relativeToConsumer, + { cwd: consumerProjectRoot }, + ); + + expect(code).toBe(`import { View as RNView } from "react-native";`); + }); + + test("an application file of the same name is still rewritten", () => { + // The guard is scoped to this package's own directories, not to a filename: + // an app with its own `components/View.tsx` must keep working. + const code = transform( + `import { View as RNView } from "react-native";`, + join("src", "components", "View.tsx"), + { cwd: join(packageRoot, "example") }, + ); + + expect(code).toBe( + `import { View as RNView } from "react-native-css/components/View";`, + ); + }); + + test("a sibling directory that merely shares a prefix is not this package", () => { + // `/src` must not swallow `/src-extra`. + const code = transform( + `import { View as RNView } from "react-native";`, + join("src-extra", "View.tsx"), + { cwd: packageRoot }, + ); + + expect(code).toBe( + `import { View as RNView } from "react-native-css/components/View";`, + ); + }); +}); + +describe("without a filename", () => { + test("babel can transform at all", () => { + // `PluginPass.filename` is `string | undefined` — babel populates it from + // `opts.filename`, which a direct `transformSync` caller need not pass. + // Reading `.startsWith` off it unconditionally throws before any rewrite is + // even considered. + expect(() => + transform(`import { View } from "react-native";`), + ).not.toThrow(); + }); + + test("nothing is rewritten, because no file can be resolved against", () => { + expect(transform(`import { View } from "react-native";`)).toBe( + `import { View } from "react-native";`, + ); + }); +}); diff --git a/src/__tests__/babel/plugin.test.mts b/src/__tests__/babel/plugin.test.mts deleted file mode 100644 index 04144446..00000000 --- a/src/__tests__/babel/plugin.test.mts +++ /dev/null @@ -1,45 +0,0 @@ -import { pluginTester } from "babel-plugin-tester"; - -import plugin from "../../babel/import-plugin"; - -pluginTester({ - plugin, - title: "plugin", - babelOptions: { - plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", - }, - tests: { - "rewrite imports from within React Native": { - only: true, - code: `import View from '../View/View';`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - babelOptions: { - filename: - "node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js", - }, - }, - "rewrite react-native imports": { - code: `import { View, Text, StyleSheet, Dimensions } from "react-native";`, - output: `import { View } from "react-native-css/dist/module/components/View"; -import { Text } from "react-native-css/dist/module/components/Text"; -import { StyleSheet } from "react-native"; -import { Dimensions } from "react-native";`, - }, - "rewrite react-native deep imports": { - code: `import { View } from "react-native/lib/components/View";`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - }, - "rewrite react-native-web imports": { - code: `import { View, Text, StyleSheet, Dimensions } from "react-native-web";`, - output: `import { View } from "react-native-css/dist/module/components/View"; -import { Text } from "react-native-css/dist/module/components/Text"; -import { StyleSheet } from "react-native-web"; -import { Dimensions } from "react-native-web";`, - }, - "rewrite react-native-web deep imports": { - code: `import { View } from "react-native-web/lib/components/View";`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - }, - }, -}); diff --git a/src/__tests__/babel/react-native-web.test.ts b/src/__tests__/babel/react-native-web.test.ts index 5d54a4da..0b0e4562 100644 --- a/src/__tests__/babel/react-native-web.test.ts +++ b/src/__tests__/babel/react-native-web.test.ts @@ -10,6 +10,12 @@ describe("react-native-web", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], }, diff --git a/src/__tests__/babel/react-native.test.ts b/src/__tests__/babel/react-native.test.ts index a0472936..1637ac25 100644 --- a/src/__tests__/babel/react-native.test.ts +++ b/src/__tests__/babel/react-native.test.ts @@ -10,9 +10,14 @@ describe("react-native", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", }, tests: appendTitles([ { diff --git a/src/__tests__/babel/smoke.test.ts b/src/__tests__/babel/smoke.test.ts index ad5c202c..9c2da9f1 100644 --- a/src/__tests__/babel/smoke.test.ts +++ b/src/__tests__/babel/smoke.test.ts @@ -10,9 +10,14 @@ describe("plugin smoke tests", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", }, tests: appendTitles([ { diff --git a/src/__tests__/metro/metro-transformer.test.ts b/src/__tests__/metro/metro-transformer.test.ts new file mode 100644 index 00000000..5cb12b16 --- /dev/null +++ b/src/__tests__/metro/metro-transformer.test.ts @@ -0,0 +1,210 @@ +import { join } from "path"; + +import type { + JsTransformerConfig, + JsTransformOptions, + TransformResponse, +} from "metro-transform-worker"; + +import type { CompilerOptions } from "../../compiler"; +import { transform } from "../../metro/metro-transformer"; +import type { WarningLevel } from "../../metro/warnings"; + +/** + * The transformer under test is the boundary a real build crosses: Metro hands + * it a `.css` file and it hands back JS. Nothing here is faked — the stock + * Expo transform worker runs, lightningcss runs, and `compile()` runs — so a + * warning observed on `console.warn` is a warning a developer running + * `expo start` would see. + */ + +const PROJECT_ROOT = process.cwd(); + +const CONFIG: JsTransformerConfig & { + reactNativeCSS?: (CompilerOptions & { warnings?: WarningLevel }) | undefined; +} = { + allowOptionalDependencies: true, + assetPlugins: [], + assetRegistryPath: "react-native/Libraries/Image/AssetRegistry", + asyncRequireModulePath: "metro-runtime/src/modules/asyncRequire", + babelTransformerPath: require.resolve( + "@expo/metro-config/build/babel-transformer", + ), + dynamicDepsInPackages: "reject", + enableBabelRCLookup: false, + enableBabelRuntime: true, + globalPrefix: "", + hermesParser: false, + minifierConfig: {}, + minifierPath: "metro-minify-terser", + optimizationSizeLimit: 150 * 1024, + publicPath: "/assets", + unstable_allowRequireContext: false, + unstable_collectDependenciesPath: require.resolve( + "metro/private/ModuleGraph/worker/collectDependencies", + ), + unstable_compactOutput: false, + unstable_disableModuleWrapping: false, + unstable_disableNormalizePseudoGlobals: false, +}; + +const OPTIONS: JsTransformOptions = { + customTransformOptions: {}, + dev: true, + experimentalImportSupport: false, + hot: false, + inlinePlatform: true, + inlineRequires: false, + minify: false, + platform: "ios", + type: "module", + unstable_transformProfile: "default", +}; + +const written: string[] = []; + +beforeEach(() => { + written.length = 0; + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + written.push(args.map((arg) => String(arg)).join(" ")); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** + * Metro's own dependencies write to `console.warn` during a transform + * (browserslist announces the age of its data, for one), so the assertion is + * over this package's lines rather than over every line. + */ +function reported(): string[] { + return written.filter((message) => message.startsWith("react-native-css:")); +} + +function runTransform(options: { + css: string; + name: string; + platform?: string; + warnings?: WarningLevel; +}): Promise { + return transform( + options.warnings === undefined + ? CONFIG + : { ...CONFIG, reactNativeCSS: { warnings: options.warnings } }, + PROJECT_ROOT, + join(PROJECT_ROOT, "src", options.name), + Buffer.from(options.css), + { ...OPTIONS, platform: options.platform ?? OPTIONS.platform }, + ); +} + +test("a dropped declaration reaches the terminal running the bundler", async () => { + const output = await runTransform({ + css: `.a { float: left; z-index: auto; color: red; }`, + name: "surfaced.css", + }); + + expect(reported()).toStrictEqual([ + [ + `react-native-css: ${join("src", "surfaced.css")} - 2 declarations dropped, no React Native equivalent`, + " properties: float", + " values: z-index: auto", + ].join("\n"), + ]); + + // The declaration the compiler DID understand still ships, so the warning is + // advisory rather than a refusal to build. + expect(JSON.stringify(output.output[0])).toContain("color"); +}); + +test("a stylesheet the compiler fully understood says nothing", async () => { + await runTransform({ + css: `.a { color: red; }`, + name: "clean.css", + }); + + expect(reported()).toStrictEqual([]); +}); + +test('"none" silences a stylesheet that would otherwise warn', async () => { + await runTransform({ + css: `.a { float: left; }`, + name: "silenced.css", + warnings: "none", + }); + + expect(reported()).toStrictEqual([]); +}); + +test('"verbose" lifts the cap', async () => { + await runTransform({ + css: `.a { border-style: hidden; } +.b { border-style: double; } +.c { border-style: groove; } +.d { border-style: ridge; } +.e { border-style: inset; } +.f { border-style: outset; } +.g { border-style: none; }`, + name: "verbose.css", + warnings: "verbose", + }); + + expect(reported()[0]).toContain( + "values: border-style: double, groove, hidden, inset, none, outset, ridge", + ); +}); + +test("re-transforming the same file with the same warnings reports once", async () => { + const css = `.a { float: left; }`; + + await runTransform({ css, name: "rebuilt.css" }); + await runTransform({ css, name: "rebuilt.css" }); + await runTransform({ css: `${css} .b { color: red; }`, name: "rebuilt.css" }); + + expect(reported()).toHaveLength(1); +}); + +test("re-transforming the same file with new warnings reports again", async () => { + await runTransform({ css: `.a { float: left; }`, name: "edited.css" }); + await runTransform({ + css: `.a { float: left; z-index: auto; }`, + name: "edited.css", + }); + + const calls = reported(); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain("values: z-index: auto"); +}); + +test("web reports nothing, because web never reaches the compiler", async () => { + const output = await runTransform({ + css: `.a { float: left; z-index: auto; }`, + name: "web.css", + platform: "web", + }); + + expect(reported()).toStrictEqual([]); + + // Not silence from having done nothing: the stock Expo transformer handled + // the file and emitted the CSS whole, `float` included. There is no compile + // on this platform, so this branch produces no warnings to surface; the + // native branch above is the only one that produces them. + const web = output as TransformResponse & { + output: [{ data: { css: { code: Buffer } } }]; + }; + expect(web.output[0].data.css.code.toString()).toContain("float"); +}); + +test("a file that is not CSS is passed through untouched", async () => { + await transform( + CONFIG, + PROJECT_ROOT, + join(PROJECT_ROOT, "src", "component.tsx"), + Buffer.from(`export const value = 1;`), + OPTIONS, + ); + + expect(reported()).toStrictEqual([]); +}); diff --git a/src/__tests__/metro/resolver.test.ts b/src/__tests__/metro/resolver.test.ts new file mode 100644 index 00000000..052d8ca5 --- /dev/null +++ b/src/__tests__/metro/resolver.test.ts @@ -0,0 +1,336 @@ +import { join } from "path"; + +import type { + CustomResolutionContext, + CustomResolver, + Resolution, +} from "metro-resolver"; + +import { transformWithBabelPlugin } from "../_transform"; +import { allowedModules } from "../../babel/allowedModules"; +import { nativeResolver, webResolver } from "../../metro/resolver"; + +/** + * A complete `CustomResolutionContext`. The resolvers read `originModulePath` + * and forward the rest untouched, but the type is honoured in full so the + * fixture needs no cast. + */ +function createResolutionContext( + originModulePath: string, + resolveRequest: CustomResolver, +): CustomResolutionContext { + return { + allowHaste: false, + assetExts: [], + customResolverOptions: {}, + disableHierarchicalLookup: false, + doesFileExist: () => false, + fileSystemLookup: () => ({ exists: false }), + getPackage: () => null, + getPackageForModule: () => null, + mainFields: [], + nodeModulesPaths: [], + originModulePath, + preferNativePlatform: false, + redirectModulePath: (modulePath: string) => modulePath, + resolveAsset: () => undefined, + resolveHasteModule: () => undefined, + resolveHastePackage: () => undefined, + resolveRequest, + sourceExts: [], + unstable_conditionNames: [], + unstable_conditionsByPlatform: {}, + unstable_enablePackageExports: false, + unstable_logWarning: () => undefined, + }; +} + +interface ResolverRun { + /** Every module name the parent resolver was asked for, in order. */ + readonly requests: string[]; + readonly resolution: Resolution; +} + +/** + * Runs one of the two resolvers against a parent that reports `filePath` for the + * first request and echoes the module name for any re-resolution. The module + * name of the LAST request is the plane's answer — the metro-side equivalent of + * the specifier the babel plane emits. + */ +function runResolver( + resolver: typeof nativeResolver, + options: { + readonly originModulePath: string; + readonly moduleName: string; + readonly filePath: string; + readonly platform: string | null; + }, +): ResolverRun { + const requests: string[] = []; + let isFirst = true; + + const parent: CustomResolver = (_context, moduleName) => { + requests.push(moduleName); + const resolved = isFirst ? options.filePath : moduleName; + isFirst = false; + return { type: "sourceFile", filePath: resolved }; + }; + + const context = createResolutionContext(options.originModulePath, parent); + const resolution = resolver( + parent, + context, + options.moduleName, + options.platform, + ); + + return { requests, resolution }; +} + +const APP_FILE = join("/project", "src", "screens", "Home.js"); + +function reactNativeLibrariesPath(component: string): string { + return join( + "/project", + "node_modules", + "react-native", + "Libraries", + "Components", + component, + `${component}.js`, + ); +} + +function reactNativeWebExportPath(component: string): string { + return join( + "/project", + "node_modules", + "react-native-web", + "dist", + "exports", + component, + "index.js", + ); +} + +/** + * The component census both planes read. Filtered to the members that are also + * JavaScript identifiers, because the babel plane can only be reached through an + * import specifier — `src/components/react-native-safe-area-context.native.tsx` + * contributes a census entry that no `import { … }` can name. + */ +const componentNames = [...allowedModules] + .filter((name) => /^[A-Z][A-Za-z0-9]*$/.test(name)) + .sort(); + +describe("the component census", () => { + test("is not empty", () => { + // Every table below is generated from this census, so an empty one would + // turn each of them into a silent no-op rather than a failure. + expect(componentNames.length).toBeGreaterThan(0); + }); +}); + +describe("nativeResolver", () => { + test("routes the react-native barrel to the components barrel", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "react-native", + filePath: join("/project", "node_modules", "react-native", "index.js"), + platform: "ios", + }); + + expect(requests.at(-1)).toBe("react-native-css/components"); + }); + + test.each(componentNames)( + "routes a resolved Libraries/Components file for %s", + (component) => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeLibrariesPath(component), + platform: "ios", + }); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + }, + ); + + test("leaves react-native's own index alone", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: join( + "/project", + "node_modules", + "react-native", + "index.js", + ), + moduleName: "react-native", + filePath: join("/project", "node_modules", "react-native", "index.js"), + platform: "ios", + }); + + expect(requests).toEqual(["react-native"]); + }); + + test("leaves a file outside react-native alone", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "./Button", + filePath: join("/project", "src", "components", "Button.js"), + platform: "ios", + }); + + expect(requests).toEqual(["./Button"]); + }); +}); + +describe("webResolver", () => { + test.each(componentNames.filter((name) => name !== "VirtualizedList"))( + "routes a resolved react-native-web export for %s", + (component) => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeWebExportPath(component), + platform: "web", + }); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + }, + ); + + test("leaves react-native-web's vendored copies alone", () => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./View", + filePath: join( + "/project", + "node_modules", + "react-native-web", + "dist", + "vendor", + "react-native", + "View", + "index.js", + ), + platform: "web", + }); + + expect(requests).toEqual(["./View"]); + }); + + test("leaves a non-index file inside an export directory alone", () => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./View/types", + filePath: join( + "/project", + "node_modules", + "react-native-web", + "dist", + "exports", + "View", + "types.js", + ), + platform: "web", + }); + + expect(requests).toEqual(["./View/types"]); + }); +}); + +describe("the metro and babel planes agree", () => { + // They are alternatives, not layers: metro's `resolveRequest` rewrites when + // `globalClassNamePolyfill` is false, and the babel plugin rewrites when it is + // true (`src/metro/index.ts`). A user flipping that flag must land on the same + // component either way, so the two implementations are pinned against each + // other over the one census they both read. + + test.each(componentNames)("react-native's %s", (component) => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeLibrariesPath(component), + platform: "ios", + }); + + const babel = transformWithBabelPlugin( + `import { ${component} } from "react-native";`, + APP_FILE, + ); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + expect(babel).toBe( + `import { ${component} } from "react-native-css/components/${component}";`, + ); + }); + + test.each(componentNames.filter((name) => name !== "VirtualizedList"))( + "react-native-web's %s", + (component) => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeWebExportPath(component), + platform: "web", + }); + + const babel = transformWithBabelPlugin( + `import { ${component} } from "react-native-web";`, + APP_FILE, + ); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + expect(babel).toBe( + `import { ${component} } from "react-native-css/components/${component}";`, + ); + }, + ); + + test("except for VirtualizedList on web, which only the babel plane rewrites", () => { + // `webResolver` excludes it by name; the babel plane has no such exclusion. + // Pinned so the asymmetry is a decision on record rather than a surprise. + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./VirtualizedList", + filePath: reactNativeWebExportPath("VirtualizedList"), + platform: "web", + }); + + expect(requests).toEqual(["./VirtualizedList"]); + expect( + transformWithBabelPlugin( + `import { VirtualizedList } from "react-native-web";`, + APP_FILE, + ), + ).toBe( + `import { VirtualizedList } from "react-native-css/components/VirtualizedList";`, + ); + }); + + test("except for react-native-safe-area-context, which only the metro plane rewrites", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "react-native-safe-area-context", + filePath: join( + "/project", + "node_modules", + "react-native-safe-area-context", + "src", + "index.tsx", + ), + platform: "ios", + }); + + expect(requests.at(-1)).toBe( + "react-native-css/components/react-native-safe-area-context", + ); + expect( + transformWithBabelPlugin( + `import { SafeAreaView } from "react-native-safe-area-context";`, + APP_FILE, + ), + ).toBe(`import { SafeAreaView } from "react-native-safe-area-context";`); + }); +}); diff --git a/src/__tests__/metro/warnings.test.ts b/src/__tests__/metro/warnings.test.ts new file mode 100644 index 00000000..0d08d3fc --- /dev/null +++ b/src/__tests__/metro/warnings.test.ts @@ -0,0 +1,464 @@ +import { join } from "path"; + +import { compile } from "../../compiler"; +import { + formatCompilerWarnings, + reportCompilerWarnings, +} from "../../metro/warnings"; + +/** + * Every fixture below is a real `compile()` result rather than a hand-written + * `CompilerWarnings` literal. A literal would agree with whatever the compiler + * happens to do — including with the compiler no longer warning at all, which + * is the failure this feature exists to make visible. + * + * The one literal in the file is the `functions` channel, and it says why. + */ +function warningsFor(css: string) { + return compile(css).warnings(); +} + +const PROJECT_ROOT = join("/project"); + +const FIFTEEN_UNSUPPORTED_PROPERTIES = `.a { + mix-blend-mode: multiply; + background-blend-mode: screen; + touch-action: none; + transform-origin: top left; + font-variant-numeric: ordinal; + border-spacing: 2px; + background-position: center; + backdrop-filter: blur(2px); + break-before: page; + break-after: page; + white-space: nowrap; + resize: both; + will-change: transform; + clear: both; + order: 2; +}`; + +const SEVEN_UNSUPPORTED_BORDER_STYLES = `.a { border-style: hidden; } +.b { border-style: double; } +.c { border-style: groove; } +.d { border-style: ridge; } +.e { border-style: inset; } +.f { border-style: outset; } +.g { border-style: none; }`; + +const written: string[] = []; + +beforeEach(() => { + written.length = 0; + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + written.push(args.map((arg) => String(arg)).join(" ")); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** + * Metro's own dependencies write to `console.warn` during a transform + * (browserslist announces its age, for one), so the assertion is over this + * package's lines rather than over every line. + */ +function reported(): string[] { + return written.filter((message) => message.startsWith("react-native-css:")); +} + +describe("formatCompilerWarnings", () => { + test("names the file, the count and the dropped property", () => { + expect( + formatCompilerWarnings(warningsFor(`.a { float: left; }`), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + " properties: float", + ].join("\n"), + ); + }); + + test("a syntax warning reaches the terminal at all", () => { + // The channel was produced end to end and rendered nowhere: `warnings.ts` + // summed only `properties`, `functions` and `values`, so a stylesheet whose + // ONLY problem was malformed CSS returned `undefined` and printed nothing. + // Every test for this channel read the producer, which is why it shipped + // green. + expect( + formatCompilerWarnings( + warningsFor(`@unknown-thing { .b { color: blue } }`), + { + displayPath: "src/global.css", + }, + ), + ).toContain("syntax: Unknown at rule: @unknown-thing"); + }); + + test("a syntax warning is NOT counted as a missing React Native equivalent", () => { + // The two are different claims and lead to different fixes. A dropped + // property is CSS this package cannot express; a syntax warning is CSS + // lightningcss could not parse, so the reader's fix is in their stylesheet. + // Folding one into the other's count puts it under a header that misdirects. + const formatted = formatCompilerWarnings( + warningsFor(`@unknown-thing { .b { color: blue } } +.a { float: left; }`), + { displayPath: "src/global.css" }, + ); + + expect(formatted).toContain( + "1 declaration dropped, no React Native equivalent", + ); + expect(formatted).toContain("properties: float"); + expect(formatted).toContain("could not be parsed"); + expect(formatted).toContain("syntax: Unknown at rule: @unknown-thing"); + }); + + test("a compile that dropped nothing formats to nothing", () => { + expect( + formatCompilerWarnings(warningsFor(`.a { color: red; }`), { + displayPath: "src/global.css", + }), + ).toBeUndefined(); + }); + + test("a repeated property is listed once and counted every time", () => { + expect( + formatCompilerWarnings( + warningsFor( + `.a { float: left; } .b { float: right; } .c { float: none; }`, + ), + { displayPath: "src/global.css" }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent", + " properties: float", + ].join("\n"), + ); + }); + + test("both channels are reported together", () => { + expect( + formatCompilerWarnings( + warningsFor(`.a { float: left; z-index: auto; }`), + { + displayPath: "src/global.css", + }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 2 declarations dropped, no React Native equivalent", + " properties: float", + " values: z-index: auto", + ].join("\n"), + ); + }); + + test("a value the compiler recorded as a number renders as text", () => { + // `line-height: 50%` is the live producer of a non-string entry: it reaches + // `addWarning("style", "line-height", 0.5)`. That is why the channel is + // typed `unknown[]`, and why rendering it is total rather than a cast. + expect( + formatCompilerWarnings(warningsFor(`.a { line-height: 50%; }`), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + " values: line-height: 0.5", + ].join("\n"), + ); + }); + + test("a value too long for a line is cut short, and verbose keeps it whole", () => { + // An unresolved `calc()` reaches the channel as a serialized lightningcss + // node — the longest value any real stylesheet produces. + const warnings = warningsFor(`.a { line-height: calc(1px + 2%); }`); + + expect( + formatCompilerWarnings(warnings, { displayPath: "src/global.css" }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + ` values: line-height: {"type":"function","value":{"type":"calc","value":{"type":"s...`, + ].join("\n"), + ); + + expect( + formatCompilerWarnings(warnings, { + displayPath: "src/global.css", + verbose: true, + }), + ).toContain(`"unit":"px"`); + }); + + test("the functions channel is rendered when it carries anything", () => { + // A literal, deliberately. `getWarnings()` declares this channel and + // `compile()` returns it, but no `addWarning` call site writes to it today, + // so no CSS can produce one. A formatter that quietly ignored it would make + // the first producer as unreachable as the whole channel is today. + expect( + formatCompilerWarnings( + { functions: ["env()", "attr()", "env()"] }, + { displayPath: "src/global.css" }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent", + " functions: attr(), env()", + ].join("\n"), + ); + }); + + test("a summary sorts, caps at ten and says how to see the rest", () => { + expect( + formatCompilerWarnings(warningsFor(FIFTEEN_UNSUPPORTED_PROPERTIES), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 15 declarations dropped, no React Native equivalent", + " properties: backdrop-filter, background-blend-mode, background-position, border-spacing, break-after, break-before, clear, font-variant-numeric, mix-blend-mode, order (+5 more)", + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ].join("\n"), + ); + }); + + test("verbose lists every entry and drops the hint", () => { + expect( + formatCompilerWarnings(warningsFor(FIFTEEN_UNSUPPORTED_PROPERTIES), { + displayPath: "src/global.css", + verbose: true, + }), + ).toBe( + [ + "react-native-css: src/global.css - 15 declarations dropped, no React Native equivalent", + " properties: backdrop-filter, background-blend-mode, background-position, border-spacing, break-after, break-before, clear, font-variant-numeric, mix-blend-mode, order, resize, touch-action, transform-origin, white-space, will-change", + ].join("\n"), + ); + }); + + test("one property's values cap at five so it cannot crowd out the rest", () => { + expect( + formatCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 7 declarations dropped, no React Native equivalent", + " values: border-style: double, groove, hidden, inset, none (+2 more)", + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ].join("\n"), + ); + }); + + test("verbose lists every value of a property too", () => { + expect( + formatCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + displayPath: "src/global.css", + verbose: true, + }), + ).toBe( + [ + "react-native-css: src/global.css - 7 declarations dropped, no React Native equivalent", + " values: border-style: double, groove, hidden, inset, none, outset, ridge", + ].join("\n"), + ); + }); +}); + +describe("reportCompilerWarnings", () => { + test("writes the block to console.warn, pathed from the project root", () => { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", "reports.css"), + projectRoot: PROJECT_ROOT, + }); + + expect(reported()).toStrictEqual([ + [ + `react-native-css: ${join("src", "reports.css")} - 1 declaration dropped, no React Native equivalent`, + " properties: float", + ].join("\n"), + ]); + }); + + test('"none" writes nothing at all', () => { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", "silenced.css"), + projectRoot: PROJECT_ROOT, + level: "none", + }); + + expect(reported()).toStrictEqual([]); + }); + + test('"verbose" writes the uncapped block', () => { + reportCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + filename: join(PROJECT_ROOT, "src", "verbose.css"), + projectRoot: PROJECT_ROOT, + level: "verbose", + }); + + expect(reported()[0]).toContain( + "values: border-style: double, groove, hidden, inset, none, outset, ridge", + ); + }); + + test("a file whose warnings have not changed is not reported twice", () => { + const filename = join(PROJECT_ROOT, "src", "unchanged.css"); + + for (let index = 0; index < 3; index += 1) { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + } + + expect(reported()).toHaveLength(1); + }); + + test("a file whose warnings changed is reported again", () => { + const filename = join(PROJECT_ROOT, "src", "changed.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { float: left; z-index: auto; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + const calls = reported(); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain("values: z-index: auto"); + }); + + test("a file that stops warning can warn again later", () => { + const filename = join(PROJECT_ROOT, "src", "fixed-then-broken.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { color: red; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + expect(reported()).toHaveLength(2); + }); + + test("each file reports on its own, not once per process", () => { + for (const name of ["first.css", "second.css"]) { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", name), + projectRoot: PROJECT_ROOT, + }); + } + + expect(reported()).toHaveLength(2); + }); + + test("a file outside the project root keeps its absolute path", () => { + const filename = join("/elsewhere", "vendor", "theme.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + expect(reported()[0]).toContain(`react-native-css: ${filename} -`); + }); +}); + +/** + * The syntax channel — a diagnostic lightningcss produced and this compiler + * discarded. + * + * lightningcss has two classes of malformed input. One THROWS, and a throw is + * already loud. The other is recovered and reported through `result.warnings` + * with no `errorRecovery` flag needed, and that return value was dropped at + * both call sites in `compiler.ts` — so an ordinary typo silently deleted a + * rule while the feature whose whole subject is surfacing compiler warnings + * said nothing. + * + * Note where the rule is lost. lightningcss passes the malformed sheet through + * verbatim; it is this package's own visitor that finds nothing to extract. + * So lightningcss's warning is the only signal that anything went wrong. + */ +describe("the syntax channel", () => { + test("an unknown at-rule swallows a rule and now says so", () => { + // `.b` is inside the unknown at-rule and does not survive. The control + // below is what makes that a finding rather than an assumption. + const compiled = compile( + `@unknown-thing { .b { color: blue } }\n.c { color: green }`, + ); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual(["c"]); + expect(compiled.warnings().syntax).toStrictEqual([ + "Unknown at rule: @unknown-thing", + ]); + }); + + test("an unrecognised pseudo-element does the same", () => { + const compiled = compile(`.a::wat { color: red }\n.c { color: green }`); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual(["c"]); + expect(compiled.warnings().syntax?.length).toBeGreaterThan(0); + }); + + test("CONTROL — the same shape, spelled correctly, keeps both rules and warns nothing", () => { + // Without this the two tests above would pass against a compiler that had + // simply stopped emitting `.b`, which is the opposite of the fix. + const compiled = compile( + `@media (min-width: 1px) { .b { color: blue } }\n.c { color: green }`, + ); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual([ + "b", + "c", + ]); + expect(compiled.warnings().syntax).toBeUndefined(); + }); + + test("CONTROL — this package's OWN at-rules are not reported as unknown", () => { + // `@react-native` and `@nativeMapping` are the two at-rules `atRules.ts` + // defines, and lightningcss calls both unknown because they are ours. A + // channel that reported them would fire on every stylesheet this compiler + // is designed to read. + expect(compile(`@react-native { }`).warnings().syntax).toBeUndefined(); + }); + + test("the same mistake made twice is reported once, two distinct ones twice", () => { + // Measured: lightningcss emits one warning per occurrence and the message + // carries no line or column, so a repeat is a second copy of a string the + // reader cannot tell apart from the first. Collapsing them is what keeps + // the channel readable — and the second assertion is what stops that + // collapse from swallowing a genuinely different diagnostic. + expect( + warningsFor( + `@unknown-a { .x { color: red } } +@unknown-a { .y { color: blue } }`, + ).syntax, + ).toStrictEqual(["Unknown at rule: @unknown-a"]); + + expect( + warningsFor( + `@unknown-a { .x { color: red } } +@unknown-b { .y { color: blue } }`, + ).syntax, + ).toStrictEqual([ + "Unknown at rule: @unknown-a", + "Unknown at rule: @unknown-b", + ]); + }); +}); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index c66f630c..fe961c53 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join, resolve, sep } from "path"; + import tBabelTypes, { type CallExpression } from "@babel/types"; export type BabelTypes = typeof tBabelTypes; @@ -10,7 +13,12 @@ export interface PluginOpts { export interface PluginState { opts?: PluginOpts; - filename: string; + /** + * Babel's `PluginPass.filename` is `string | undefined`: absolute when + * `opts.filename` was given (babel resolves it against `cwd`), and `undefined` + * when a `transformSync` caller passed none. + */ + filename: string | undefined; } export function getInteropRequireDefaultSource( @@ -38,3 +46,80 @@ export function getInteropRequireDefaultSource( return requireArg.value; } + +/** + * Rewrite a host path's separators as POSIX ones. + * + * `hostSeparator` is `path.sep`, taken as an argument rather than read from the + * module. It is the entire decision this function makes, and a test that cannot + * supply it can only ever observe the branch its own host happens to take — CI + * runs ubuntu-latest plus one macos-15, so the Windows branch would be exercised + * nowhere. + * + * The gate is not cosmetic. On POSIX a backslash is a legal filename character, + * so `/project/weird\name.js` is one file and rewriting it would name a + * different, non-existent path. + */ +export function toPosixPath(path: string, hostSeparator: string): string { + return hostSeparator === "/" ? path : path.replaceAll("\\", "/"); +} + +/** + * Resolve a relative import source against the file that contains it, in POSIX + * separators. + * + * Two properties of the result are load-bearing, and both belong here rather + * than at the call sites: + * + * - **The base is the file's directory.** `filename` is babel's path of the FILE + * being transformed (`PluginPass.filename` is `file.opts.filename`), so + * `./x` beside it is `dirname(filename)/x`. Resolving against the filename + * itself consumes one `..` too few and moves the package boundary by one + * directory. Taking the base is part of the operation, which is why this + * signature is `(filename, source)` and not a variadic resolve: the caller is + * given no base to get wrong. + * - **The separators are POSIX.** Callers match the result against forward-slash + * literals (`react-native/Libraries/Components/`, `react-native-web/dist`) and + * re-emit its tail as a module specifier, which is forward-slash by + * definition. On Windows `path.resolve` yields backslashes, so those matches + * silently miss and the import is left un-rewritten. + */ +export function resolveImportSource(filename: string, source: string): string { + return toPosixPath(resolve(dirname(filename), source), sep); +} + +function declaresName(manifestPath: string): boolean { + const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + + return typeof parsed === "object" && parsed !== null && "name" in parsed; +} + +/** + * The directory of the package `from` belongs to. + * + * The babel plugin sits at `/src/babel/` in the tree and at + * `/dist//babel/` once built, so no fixed number of `..` names the + * root in both — a constant written for one layout is silently wrong in the + * other. The manifest names it, with one wrinkle: react-native-builder-bob + * writes a bare `{ "type": … }` package.json into each output directory + * (`react-native-builder-bob/lib/src/utils/compile.js`), so the walk looks for + * the nearest manifest that declares a `name`. + */ +export function findPackageRoot(from: string): string { + let directory = from; + + for (;;) { + const manifest = join(directory, "package.json"); + + if (existsSync(manifest) && declaresName(manifest)) { + return directory; + } + + const parent = dirname(directory); + if (parent === directory) { + throw new Error(`No named package.json above ${from}`); + } + + directory = parent; + } +} diff --git a/src/babel/import-plugin.ts b/src/babel/import-plugin.ts index 7c7c0ac0..053fd62d 100644 --- a/src/babel/import-plugin.ts +++ b/src/babel/import-plugin.ts @@ -1,9 +1,10 @@ -import { resolve } from "path"; +import { join, sep } from "path"; import { type PluginObj } from "@babel/core"; import type { Statement } from "@babel/types"; import { + findPackageRoot, getInteropRequireDefaultSource, type BabelTypes, type PluginState, @@ -24,32 +25,57 @@ export default function ({ }: { types: BabelTypes; }): PluginObj { - const processed = new WeakSet(); - - const thisModuleDist = resolve(__dirname, "../../../dist"); - const thisModuleSrc = resolve(__dirname, "../../../src"); - + // Nodes this plugin generated. `replaceWithMultiple` requeues its replacements, + // so a rewrite that reproduces its own input — `const { Platform } = + // require("react-native")` — would otherwise be visited and rewritten forever. + // The set holds `Statement` nodes, never the `NodePath`s wrapping them, and the + // element type says so: `processed.has(path)` is a compile error, not a + // disjunct that is quietly always false. + const processed = new WeakSet(); + + // This package's own components import the primitive they wrap, so rewriting + // one turns it into an import of itself. These are the two directories it + // ships (`package.json`'s `files`), each with a trailing separator so a + // sibling like `/src-extra` is not swallowed by the prefix. + const packageRoot = findPackageRoot(__dirname); + const ownDirectories = [ + join(packageRoot, "dist") + sep, + join(packageRoot, "src") + sep, + ]; + + /** + * `filename` is already absolute: babel stores `path.resolve(cwd, opts.filename)` + * (`@babel/core/lib/config/partial.js`), so metro handing it a project-relative + * name (`metro/src/DeltaBundler/Transformer.js` passes + * `path.relative(projectRoot, filePath)`) still arrives here resolved against + * `cwd`, which `metro-babel-transformer` sets to the project root. Both sides of + * the comparison are OS-native absolute paths, so no separator normalization + * belongs here. + */ function isFromThisModule(filename: string): boolean { - return ( - filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) - ); + return ownDirectories.some((directory) => filename.startsWith(directory)); } return { name: "Rewrite react-native to react-native-css", visitor: { ImportDeclaration(path, state): void { + const { filename } = state; + + // Without a filename nothing can be resolved against, and the guard below + // has nothing to compare. `PluginPass.filename` is `string | undefined` + // precisely because a direct `transformSync` caller need not supply one. if ( - processed.has(path) || + filename === undefined || processed.has(path.node) || - isFromThisModule(state.filename) + isFromThisModule(filename) ) { return; } const statements = - handleReactNativeImport(path.node, t, state.filename) ?? - handleReactNativeWebImport(path.node, t, state.filename); + handleReactNativeImport(path.node, t, filename) ?? + handleReactNativeWebImport(path.node, t, filename); if (!statements) { return; @@ -62,10 +88,12 @@ export default function ({ path.replaceWithMultiple(statements); }, VariableDeclaration(path, state): void { + const { filename } = state; + if ( - processed.has(path) || + filename === undefined || processed.has(path.node) || - isFromThisModule(state.filename) + isFromThisModule(filename) ) { return; } @@ -114,14 +142,14 @@ export default function ({ t, id.name, initArg.value, - state.filename, + filename, ) ?? handleReactNativeWebIdentifierRequire( path, t, id.name, initArg.value, - state.filename, + filename, ); } else if ( t.isObjectPattern(id) && @@ -134,14 +162,14 @@ export default function ({ t, id, initArg.value, - state.filename, + filename, ) ?? handleReactNativeWebObjectPatternRequire( path, t, id, initArg.value, - state.filename, + filename, ); } else if ( t.isIdentifier(id) && @@ -157,7 +185,7 @@ export default function ({ t, id.name, source, - state.filename, + filename, ); } diff --git a/src/babel/react-native-web.ts b/src/babel/react-native-web.ts index 1bacf5a0..6262caf1 100644 --- a/src/babel/react-native-web.ts +++ b/src/babel/react-native-web.ts @@ -1,5 +1,3 @@ -import { resolve } from "path"; - import { type NodePath } from "@babel/traverse"; import tBabelTypes, { type ImportDeclaration, @@ -9,12 +7,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; +import { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeWebSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(filename, source); + source = resolveImportSource(filename, source); const internalPath = source.split("react-native-web/dist")[1]; if (!internalPath) { diff --git a/src/babel/react-native.ts b/src/babel/react-native.ts index 2522a848..ab30c134 100644 --- a/src/babel/react-native.ts +++ b/src/babel/react-native.ts @@ -1,5 +1,3 @@ -import { dirname, resolve } from "path"; - import { type NodePath } from "@babel/traverse"; import tBabelTypes, { type ImportDeclaration, @@ -9,12 +7,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; +import { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(dirname(filename), source); + source = resolveImportSource(filename, source); const internalPath = source.split("react-native/Libraries/Components/")[1]; if (!internalPath) { diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 214cd615..f86c5c56 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -11,6 +11,7 @@ import { type PropertyRule, type Rule, type Visitor, + type Warning, } from "lightningcss"; import { maybeMutateReactNativeOptions, parsePropAtRule } from "./atRules"; @@ -46,6 +47,45 @@ const defaultLogger = debug("react-native-css:compiler"); * @param options - Compiler options * @returns A `ReactNativeCssStyleSheet` that can be passed to `StyleSheet.register` or used with a custom runtime */ +/** + * The two at-rules this package defines itself. + * + * lightningcss calls both unknown because they are ours — `@react-native` is a + * registered `customAtRules` entry and `@nativeMapping` is handled as an + * unknown rule (`atRules.ts`). Reporting them would fire the channel on every + * stylesheet this compiler is designed to read. + */ +const OWN_AT_RULE_WARNINGS = /^Unknown at rule: @(nativeMapping|react-native)$/u; + +/** + * Hand lightningcss's own parse diagnostics to the builder. + * + * These are the recovered-and-warned class: input lightningcss could not parse + * but did not throw over. It passes the malformed sheet through verbatim, so + * nothing fails — this package's visitor simply finds nothing to extract and + * the rule disappears. That makes this warning the ONLY signal the author has. + * + * Only the FIRST pass is read. The second re-parses the first's output, and + * measured against both reachable triggers it returns the identical message — + * so reading it would add nothing but a duplicate to suppress. An unknown + * at-rule and an unrecognised pseudo-element each warn once per pass, with the + * same text. + * + * Deliberately NOT paired with lightningcss's `errorRecovery` flag. That + * converts the throwing class into more of this one, which is a change to what + * compiles rather than a diagnostic — and it drops more than it reports. + */ +function reportSyntaxWarnings( + builder: StylesheetBuilder, + warnings: Warning[] | undefined, +): void { + for (const warning of warnings ?? []) { + if (!OWN_AT_RULE_WARNINGS.test(warning.message)) { + builder.addSyntaxWarning(warning.message); + } + } +} + export function compile(code: Buffer | string, options: CompilerOptions = {}) { const { logger = defaultLogger } = options; @@ -136,7 +176,7 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { }; } - const { code: firstPass } = lightningcss({ + const { code: firstPass, warnings: firstPassWarnings } = lightningcss({ code: typeof code === "string" ? new TextEncoder().encode(code) : code, include: Features.DoublePositionGradients | Features.ColorFunction, exclude: Features.VendorPrefixes, @@ -197,6 +237,8 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { projectRoot: options.projectRoot ?? process.cwd(), }); + reportSyntaxWarnings(builder, firstPassWarnings); + return { stylesheet: () => builder.getNativeStyleSheet(), warnings: () => builder.getWarnings(), diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..2639a99d 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -24,6 +24,36 @@ export interface InlineVariableOptions { exclude?: `--${string}`[]; } +/** + * The advisory diagnostics a compile recorded, as returned by + * `compile(css).warnings()`. + * + * Every channel is absent rather than empty when nothing was recorded, so an + * object with no keys is the "nothing was dropped" answer. + */ +export interface CompilerWarnings { + /** Properties with no React Native equivalent. The declaration is dropped. */ + properties?: string[]; + /** + * Values that could not be translated, keyed by the property that held them. + * + * The recorded value is whatever the compiler had in hand at the point it + * gave up — a string for most declarations, a lightningcss node for some — + * so consumers must render it defensively. + */ + values?: Record; + /** CSS functions with no React Native equivalent. */ + functions?: string[]; + /** + * Declarations lightningcss could not parse at all. + * + * Distinct from `properties` and `values`, which are things this compiler + * understood and cannot express: a syntax warning is malformed CSS, so the + * reader's fix is in their stylesheet rather than in this package. + */ + syntax?: string[]; +} + /** * A `react-native-css` StyleSheet */ diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..41bdbf69 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -9,6 +9,7 @@ import type { AnimationKeyframes, AnimationRecord, CompilerOptions, + CompilerWarnings, ContainerQuery, MediaCondition, ReactNativeCssStyleSheet, @@ -70,6 +71,7 @@ export class StylesheetBuilder { warningProperties: string[]; warningValues: Record; warningFunctions: string[]; + syntaxWarnings: Set; } = { ruleSets: {}, rem: 14, @@ -77,6 +79,7 @@ export class StylesheetBuilder { warningProperties: [], warningValues: {}, warningFunctions: [], + syntaxWarnings: new Set(), }, private selectors: SelectorList = [], ) {} @@ -227,12 +230,19 @@ export class StylesheetBuilder { } } - getWarnings() { - const result: { - properties?: string[]; - values?: Record; - functions?: string[]; - } = {}; + /** + * A diagnostic lightningcss produced while parsing. + * + * A `Set` rather than an array because the compiler runs lightningcss twice + * and the second pass re-parses the first pass's output, so one authoring + * mistake arrives from both. + */ + addSyntaxWarning(message: string): void { + this.shared.syntaxWarnings.add(message); + } + + getWarnings(): CompilerWarnings { + const result: CompilerWarnings = {}; if (this.shared.warningProperties.length) { result.properties = this.shared.warningProperties; @@ -246,6 +256,10 @@ export class StylesheetBuilder { result.functions = this.shared.warningFunctions; } + if (this.shared.syntaxWarnings.size) { + result.syntax = [...this.shared.syntaxWarnings]; + } + return result; } diff --git a/src/metro/index.ts b/src/metro/index.ts index 73c041c7..642894aa 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -6,6 +6,7 @@ import type { MetroConfig } from "metro-config"; import { type CompilerOptions } from "../compiler"; import { nativeResolver, webResolver } from "./resolver"; import { setupTypeScript } from "./typescript"; +import type { WarningLevel } from "./warnings"; export interface WithReactNativeCSSOptions extends CompilerOptions { /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */ @@ -15,8 +16,17 @@ export interface WithReactNativeCSSOptions extends CompilerOptions { /** Add className to all React Native primitives. Defaults false */ globalClassNamePolyfill?: boolean; hexColors?: boolean; + /** + * How the compiler's warnings are surfaced while bundling. Defaults + * "summary": one deduplicated, capped block per CSS file, printed only when + * that file's set of warnings changes. "verbose" lists every entry, "none" + * prints nothing. Native only — a web bundle never runs the compiler. + */ + warnings?: WarningLevel; } +export type { WarningLevel } from "./warnings"; + const metroOverrideResolution = { type: "sourceFile", filePath: require.resolve("./override"), diff --git a/src/metro/metro-transformer.ts b/src/metro/metro-transformer.ts index e56e1ca7..2bbf84fa 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -7,6 +7,7 @@ import type { import { compile, type CompilerOptions } from "../compiler"; import { getNativeInjectionCode } from "./injection-code"; +import { reportCompilerWarnings, type WarningLevel } from "./warnings"; const worker = // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -14,7 +15,9 @@ const worker = export async function transform( config: JsTransformerConfig & { - reactNativeCSS?: CompilerOptions | undefined; + reactNativeCSS?: + | (CompilerOptions & { warnings?: WarningLevel | undefined }) + | undefined; }, projectRoot: string, filePath: string, @@ -36,11 +39,25 @@ export async function transform( const css = cssFile.output[0].data.css.code.toString(); - const productionJS = compile(css, { - ...config.reactNativeCSS, + const { warnings: warningLevel, ...compilerOptions } = + config.reactNativeCSS ?? {}; + + const compiled = compile(css, { + ...compilerOptions, filename: filePath, projectRoot: projectRoot, - }).stylesheet(); + }); + + const productionJS = compiled.stylesheet(); + + // The compiler records every declaration it could not translate. This is the + // only place a real build can read them — nothing downstream of the + // transformer ever sees the compile result again. + reportCompilerWarnings(compiled.warnings(), { + filename: filePath, + projectRoot, + level: warningLevel, + }); data = Buffer.from(getNativeInjectionCode([], [productionJS])); diff --git a/src/metro/warnings.ts b/src/metro/warnings.ts new file mode 100644 index 00000000..56a1df00 --- /dev/null +++ b/src/metro/warnings.ts @@ -0,0 +1,318 @@ +import { relative } from "path"; + +import type { CompilerWarnings } from "../compiler/compiler.types"; + +/** + * How a Metro build surfaces the compiler's advisory warnings. + * + * - `summary` (the default) prints one deduplicated, capped block per CSS file. + * - `verbose` prints the same block with every entry listed. + * - `none` prints nothing. + */ +export type WarningLevel = "none" | "summary" | "verbose"; + +/** + * The number of distinct entries a `summary` block lists per channel before it + * collapses the rest into a `(+N more)` count. + * + * Ten is a measured figure rather than a round one. Compiling the Tailwind + * corpus in `src/__tests__/vendor/tailwind` warns on 87 distinct properties, so + * an uncapped block would be a screen of output on the most common setup this + * package has; ten fits a terminal line and still names the properties a reader + * is most likely to recognise. + */ +const SUMMARY_ENTRY_LIMIT = 10; + +/** + * The number of distinct values a `summary` block lists for a single property. + * + * Separate from the entry limit because the two truncate different things: a + * property with six unusable values is one line, not six. + */ +const SUMMARY_VALUE_LIMIT = 5; + +/** The characters a `summary` block prints of any single value. */ +const SUMMARY_VALUE_LENGTH_LIMIT = 60; + +const PREFIX = "react-native-css"; + +/** + * The last message emitted for a file, keyed by its absolute path. + * + * This is the noise model, and it is deliberately not "print everything". + * + * Metro transforms a file whenever its contents change, and a Tailwind build + * rewrites its CSS output on nearly every source save — so a transformer that + * printed unconditionally would reprint an identical block on every keystroke + * for the whole session. Keying on the message means a block prints when the + * set of warnings for a file CHANGES, which is the only moment a reader learns + * something: the first compile, and every time a fix removes an entry or a new + * declaration adds one. + * + * The map is bounded by the number of CSS files in the project rather than by + * the number of transforms, and it lives per worker process, so a restarted + * bundler reprints. Nothing here decides correctness — a dropped entry costs a + * repeated line, never a wrong build. + */ +const lastReportedByFile = new Map(); + +/** + * Surface a compile's warnings on the terminal running the bundler. + * + * Writes through `console.warn`, which is what makes it reachable: Metro pipes + * each transform worker's stderr to the parent and its reporter prints the + * chunk (`WorkerFarm` forwards it as `worker_stderr_chunk`; `TerminalReporter` + * logs it). With `maxWorkers: 1` the worker is required in-band and the write + * lands on the bundler's own stderr. Neither path fails the build — these are + * advisory, and a dropped `float` is not a reason to refuse to bundle. + */ +export function reportCompilerWarnings( + warnings: CompilerWarnings, + options: { + filename: string; + projectRoot: string; + level?: WarningLevel | undefined; + }, +): void { + const { filename, projectRoot, level = "summary" } = options; + + if (level === "none") { + return; + } + + const message = formatCompilerWarnings(warnings, { + displayPath: toDisplayPath(filename, projectRoot), + verbose: level === "verbose", + }); + + if (message === undefined) { + // A file that stops warning must be able to warn again later, so the + // absence is recorded by forgetting it rather than by storing an empty + // message. + lastReportedByFile.delete(filename); + return; + } + + if (lastReportedByFile.get(filename) === message) { + return; + } + + lastReportedByFile.set(filename, message); + + console.warn(message); +} + +/** + * Render a compile's warnings as a single block, or `undefined` when the + * compile recorded none. + * + * The block is plain text with no ANSI escapes. Metro runs transformers in + * `jest-worker` children whose stdio is piped, so `process.stdout.isTTY` is + * unset there and the vendored `picocolors` would disable itself anyway — + * colour would appear only in the `maxWorkers: 1` in-band case, which is a + * worse outcome than never colouring at all. + */ +export function formatCompilerWarnings( + warnings: CompilerWarnings, + options: { displayPath: string; verbose?: boolean | undefined }, +): string | undefined { + const { displayPath, verbose = false } = options; + + const droppedCount = + (warnings.properties?.length ?? 0) + + (warnings.functions?.length ?? 0) + + Object.values(warnings.values ?? {}).reduce( + (total, entries) => total + entries.length, + 0, + ); + + // Counted apart from the three above, and reported as its own clause, because + // it is a different claim. A dropped declaration is CSS this package cannot + // EXPRESS; a syntax warning is CSS lightningcss could not PARSE. They send a + // reader to different places — one to this package's limits, one to their own + // stylesheet — so folding syntax into `droppedCount` would file it under a + // header that misdirects. + const syntaxCount = warnings.syntax?.length ?? 0; + + if (droppedCount === 0 && syntaxCount === 0) { + return undefined; + } + + const properties = limit(unique(warnings.properties), verbose); + const functions = limit(unique(warnings.functions), verbose); + const rendered = renderValues(warnings.values, verbose); + const values = limit(rendered.entries, verbose); + const syntax = limit(unique(warnings.syntax), verbose); + + // One header carrying whichever claims apply, so a file with both problems + // reports both rather than the first one found. + const claims: string[] = []; + if (droppedCount > 0) { + claims.push( + `${droppedCount} ${pluralize(droppedCount, "declaration")} dropped, no React Native equivalent`, + ); + } + if (syntaxCount > 0) { + claims.push( + `${syntaxCount} ${pluralize(syntaxCount, "rule")} could not be parsed`, + ); + } + + const lines = [`${PREFIX}: ${displayPath} - ${claims.join("; ")}`]; + + if (syntax.shown.length > 0) { + lines.push(` syntax: ${syntax.shown.join("; ")}${suffix(syntax.hidden)}`); + } + + if (properties.shown.length > 0) { + lines.push( + ` properties: ${properties.shown.join(", ")}${suffix(properties.hidden)}`, + ); + } + + if (values.shown.length > 0) { + lines.push(` values: ${values.shown.join("; ")}${suffix(values.hidden)}`); + } + + if (functions.shown.length > 0) { + lines.push( + ` functions: ${functions.shown.join(", ")}${suffix(functions.hidden)}`, + ); + } + + if ( + properties.hidden + + values.hidden + + functions.hidden + + rendered.hidden + + syntax.hidden > + 0 + ) { + lines.push( + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ); + } + + return lines.join("\n"); +} + +/** + * The path a reader recognises: relative to the project root when the file is + * inside it, absolute when it is not. + * + * Separators are the host's. A Windows developer reads a Windows path, and + * every assertion over this value can build its expectation with `path.join` + * rather than hardcoding one host's shape. + */ +function toDisplayPath(filename: string, projectRoot: string): string { + const relativePath = relative(projectRoot, filename); + + return relativePath === "" || relativePath.startsWith("..") + ? filename + : relativePath; +} + +/** + * One entry per property that carried an unusable value, each listing that + * property's distinct values. + * + * Grouped rather than flattened because the two truncate differently: a + * property with six unusable values is one line worth reading, not six lines + * that push five other properties out of the block. + */ +function renderValues( + values: Record | undefined, + verbose: boolean, +): { entries: string[]; hidden: number } { + let hidden = 0; + const entries: string[] = []; + + for (const [property, recorded] of Object.entries(values ?? {}).sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0), + )) { + const distinct = unique(recorded.map(stringify)); + + if (distinct.length === 0) { + continue; + } + + const shown = verbose ? distinct : distinct.slice(0, SUMMARY_VALUE_LIMIT); + hidden += distinct.length - shown.length; + + const rendered = shown.map((value) => truncate(value, verbose)); + + entries.push( + `${property}: ${rendered.join(", ")}${suffix(distinct.length - shown.length)}`, + ); + } + + return { entries, hidden }; +} + +function limit(entries: T[], verbose: boolean) { + if (verbose || entries.length <= SUMMARY_ENTRY_LIMIT) { + return { shown: entries, hidden: 0 }; + } + + return { + shown: entries.slice(0, SUMMARY_ENTRY_LIMIT), + hidden: entries.length - SUMMARY_ENTRY_LIMIT, + }; +} + +function suffix(hidden: number): string { + return hidden > 0 ? ` (+${hidden} more)` : ""; +} + +/** + * Distinct entries in code-unit order. + * + * Not `localeCompare`: the order of this list is asserted by tests that run on + * three operating systems, and collation is an ICU-and-locale decision. Code + * units are the same everywhere, and for CSS identifiers they read the same as + * alphabetical. + */ +function unique(entries: string[] | undefined): string[] { + return entries ? [...new Set(entries)].sort() : []; +} + +/** + * `values` is `Record` because the compiler records whatever + * it could not translate: a string for most declarations, a number for + * `line-height`, and a serialized lightningcss node for an unresolved `calc()`. + * Rendering is therefore total rather than a cast. + */ +function stringify(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === null || typeof value !== "object") { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + // A warning must never be the thing that fails a build, and + // `JSON.stringify` throws on a circular value. + return "[unserializable]"; + } +} + +/** + * Keep one value from taking a whole terminal line. + * + * An unresolved `calc()` is recorded as its serialized lightningcss node, which + * runs to a few hundred characters — long enough to bury the other entries in + * the block it appears in. `verbose` prints it whole. + */ +function truncate(value: string, verbose: boolean): string { + return verbose || value.length <= SUMMARY_VALUE_LENGTH_LIMIT + ? value + : `${value.slice(0, SUMMARY_VALUE_LENGTH_LIMIT)}...`; +} + +function pluralize(count: number, noun: string): string { + return count === 1 ? noun : `${noun}s`; +}