From 61f8ef142470370b57e1cfe1062341a1027aee67 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:40:04 +0300 Subject: [PATCH] feat(metro): surface compiler warnings during a build The compiler records every declaration it could not translate, but nothing read compile().warnings() during a build, so a dropped float or z-index: auto was silent. The Metro transformer now prints one block per CSS file: capped in summary mode, listed in full in verbose mode, and printed again only when the file's warnings change. The warnings option sets the level, and none silences it. lightningcss also warns about input it cannot parse, such as an unknown at-rule or pseudo-element, whose rule then disappears. Those warnings reach a new syntax channel, kept apart from what React Native cannot express; this package's own @nativeMapping is not reported. --- README.md | 22 ++ src/__tests__/compiler/warnings.test.ts | 65 ++++ src/__tests__/metro/_transform-worker.ts | 16 + src/__tests__/metro/metro-transformer.test.ts | 143 +++++++ src/__tests__/metro/warnings.test.ts | 355 ++++++++++++++++++ src/compiler/compiler.ts | 21 +- src/compiler/compiler.types.ts | 10 + src/compiler/stylesheet.ts | 20 +- src/metro/index.ts | 5 + src/metro/metro-transformer.ts | 23 +- src/metro/warnings.ts | 207 ++++++++++ 11 files changed, 876 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/compiler/warnings.test.ts create mode 100644 src/__tests__/metro/_transform-worker.ts create mode 100644 src/__tests__/metro/metro-transformer.test.ts create mode 100644 src/__tests__/metro/warnings.test.ts create mode 100644 src/metro/warnings.ts 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__/compiler/warnings.test.ts b/src/__tests__/compiler/warnings.test.ts new file mode 100644 index 00000000..5c09c882 --- /dev/null +++ b/src/__tests__/compiler/warnings.test.ts @@ -0,0 +1,65 @@ +import { compile } from "react-native-css/compiler"; + +function ruleNames(css: string) { + return compile(css) + .stylesheet() + .s?.map(([name]) => name); +} + +describe("the syntax channel", () => { + test("an unknown at-rule drops the rules inside it, and says so", () => { + const css = `@unknown-thing { .b { color: blue } }\n.c { color: green }`; + + expect(ruleNames(css)).toStrictEqual(["c"]); + expect(compile(css).warnings()).toStrictEqual({ + syntax: ["Unknown at rule: @unknown-thing"], + }); + }); + + test("an unrecognised pseudo-element drops its rule, and says so", () => { + const css = `.a::wat { color: red }\n.c { color: green }`; + + expect(ruleNames(css)).toStrictEqual(["c"]); + expect(compile(css).warnings()).toStrictEqual({ + syntax: [ + "'wat' is not recognized as a valid pseudo-element. Did you mean ':wat' (pseudo-class) or is this a typo?", + ], + }); + }); + + test("the same shape spelled correctly keeps both rules and warns nothing", () => { + const css = `@media (min-width: 1px) { .b { color: blue } }\n.c { color: green }`; + + expect(ruleNames(css)).toStrictEqual(["b", "c"]); + expect(compile(css).warnings()).toStrictEqual({}); + }); + + test.each([ + "@nativeMapping {}", + ".a { @nativeMapping { color: textColor; } color: red }", + ])("this package's own at-rule is not reported: %s", (css) => { + expect(compile(css).warnings()).toStrictEqual({}); + }); + + test("a repeated mistake is one entry, and two different ones are two", () => { + expect( + compile(`@unknown-a { .x { color: red } } +@unknown-a { .y { color: blue } }`).warnings().syntax, + ).toStrictEqual(["Unknown at rule: @unknown-a"]); + + expect( + compile(`@unknown-a { .x { color: red } } +@unknown-b { .y { color: blue } }`).warnings().syntax, + ).toStrictEqual([ + "Unknown at rule: @unknown-a", + "Unknown at rule: @unknown-b", + ]); + }); +}); + +// float rather than a property React Native may yet gain: Yoga lays out flexbox and has no floats +test("a property with no React Native equivalent is reported on its own channel", () => { + expect(compile(`.a { float: left }`).warnings()).toStrictEqual({ + properties: ["float"], + }); +}); diff --git a/src/__tests__/metro/_transform-worker.ts b/src/__tests__/metro/_transform-worker.ts new file mode 100644 index 00000000..6b69e467 --- /dev/null +++ b/src/__tests__/metro/_transform-worker.ts @@ -0,0 +1,16 @@ +import type { JsTransformOptions } from "metro-transform-worker"; + +// Stands in for Expo's transform worker: on web a stylesheet comes back as CSS, and anything else as code +export function transform( + _config: unknown, + _projectRoot: string, + filename: string, + data: Buffer, + options: JsTransformOptions, +) { + return Promise.resolve( + options.platform === "web" && filename.endsWith(".css") + ? { output: [{ data: { css: { code: data } } }] } + : { output: [{ data: { code: data.toString() } }] }, + ); +} diff --git a/src/__tests__/metro/metro-transformer.test.ts b/src/__tests__/metro/metro-transformer.test.ts new file mode 100644 index 00000000..ca4fc896 --- /dev/null +++ b/src/__tests__/metro/metro-transformer.test.ts @@ -0,0 +1,143 @@ +import { join } from "path"; + +import type { JsTransformOptions } from "metro-transform-worker"; + +import { transform } from "../../metro/metro-transformer"; +import type { WarningLevel } from "../../metro/warnings"; + +type TransformerConfig = Parameters[0]; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_transform-worker"), +})); + +const PROJECT_ROOT = process.cwd(); + +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(); +}); + +function reported(): string[] { + return written.filter((message) => message.startsWith("react-native-css:")); +} + +function runTransform(options: { + css: string; + name: string; + platform?: string; + warnings?: WarningLevel; +}) { + // The stand-in worker reads neither, so each carries only what the transformer reads + const config: Pick = { + reactNativeCSS: + options.warnings === undefined + ? undefined + : { warnings: options.warnings }, + }; + const transformOptions: Pick = { + type: "module", + platform: options.platform ?? "ios", + }; + + return transform( + config as TransformerConfig, + PROJECT_ROOT, + join(PROJECT_ROOT, "src", options.name), + Buffer.from(options.css), + transformOptions as JsTransformOptions, + ); +} + +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"), + ]); + 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("a rebuilt file reports again only when its warnings change", 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" }); + await runTransform({ + css: `${css} .b { z-index: auto; }`, + name: "rebuilt.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 () => { + await runTransform({ + css: `.a { float: left; z-index: auto; }`, + name: "web.css", + platform: "web", + }); + + expect(reported()).toStrictEqual([]); +}); + +test("a file that is not CSS is passed through untouched", async () => { + await runTransform({ + css: `export const value = 1;`, + name: "component.tsx", + }); + + expect(reported()).toStrictEqual([]); +}); diff --git a/src/__tests__/metro/warnings.test.ts b/src/__tests__/metro/warnings.test.ts new file mode 100644 index 00000000..d3954357 --- /dev/null +++ b/src/__tests__/metro/warnings.test.ts @@ -0,0 +1,355 @@ +import { join } from "path"; + +import { compile } from "../../compiler"; +import { + formatCompilerWarnings, + reportCompilerWarnings, +} from "../../metro/warnings"; + +// Fixtures are real compile() results, so a compiler that stopped warning fails here too; the functions channel alone is a literal +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 also write to console.warn, so assertions read this package's lines +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", () => { + 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", () => { + 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 recorded as the number 0.5 + 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() is recorded as its lightningcss node, the longest value a 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: no compile path records a function today, and the formatter must still render one + 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} -`); + }); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..847d8109 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -10,6 +10,7 @@ import { type PropertyRule, type Rule, type Visitor, + type Warning, } from "lightningcss"; import { @@ -42,6 +43,21 @@ import { supportsConditionValid } from "./supports"; const defaultLogger = debug("react-native-css:compiler"); +// lightningcss reports this package's own at-rule as unknown +const OWN_AT_RULE_WARNING = "Unknown at rule: @nativeMapping"; + +// lightningcss passes a rule it cannot parse through and the visitor drops it, so its warning is the only signal; only the first pass reads the author's CSS +function reportSyntaxWarnings( + builder: StylesheetBuilder, + warnings: Warning[], +): void { + for (const warning of warnings) { + if (warning.message !== OWN_AT_RULE_WARNING) { + builder.addSyntaxWarning(warning.message); + } + } +} + /** * Converts a CSS file to a collection of style declarations that can be used with the StyleSheet API * @@ -49,6 +65,7 @@ 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 */ + export function compile(code: Buffer | string, options: CompilerOptions = {}) { const { logger = defaultLogger } = options; @@ -170,7 +187,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, @@ -231,6 +248,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..b085c09c 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -24,6 +24,16 @@ export interface InlineVariableOptions { exclude?: `--${string}`[]; } +// A channel is absent when the compile recorded nothing on it +export interface CompilerWarnings { + properties?: string[]; + // A value is recorded as the compiler held it: a string, a number or a lightningcss node + values?: Record; + functions?: string[]; + // CSS lightningcss could not parse, which the author fixes in their stylesheet + syntax?: string[]; +} + /** * A `react-native-css` StyleSheet */ diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..431b7ad5 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,13 @@ export class StylesheetBuilder { } } - getWarnings() { - const result: { - properties?: string[]; - values?: Record; - functions?: string[]; - } = {}; + // A lightningcss message carries no position, so a repeated mistake is one entry + 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 +250,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 63290e59..cbb93d9f 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -7,6 +7,7 @@ import { type CompilerOptions } from "../compiler"; import { getCacheVersion } from "./cache-version"; 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 */ @@ -16,8 +17,12 @@ export interface WithReactNativeCSSOptions extends CompilerOptions { /** Add className to all React Native primitives. Defaults false */ globalClassNamePolyfill?: boolean; hexColors?: boolean; + /** How native builds print the compiler's warnings. Defaults "summary" */ + 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 16eb4f53..f9a0afce 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -6,6 +6,7 @@ import type { import { compile, type CompilerOptions } from "../compiler"; import { getNativeInjectionCode } from "./injection-code"; +import { reportCompilerWarnings, type WarningLevel } from "./warnings"; type TransformResponse = Awaited< ReturnType @@ -17,7 +18,9 @@ const worker = export async function transform( config: JsTransformerConfig & { - reactNativeCSS?: CompilerOptions | undefined; + reactNativeCSS?: + | (CompilerOptions & { warnings?: WarningLevel | undefined }) + | undefined; }, projectRoot: string, filePath: string, @@ -39,11 +42,23 @@ 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(); + + // Nothing downstream of the transformer sees the compile result, so this is the one place a build can report it + 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..e6059386 --- /dev/null +++ b/src/metro/warnings.ts @@ -0,0 +1,207 @@ +import { relative } from "path"; + +import type { CompilerWarnings } from "../compiler/compiler.types"; + +/** "summary" prints one capped block per CSS file, "verbose" lists every entry, "none" prints nothing */ +export type WarningLevel = "none" | "summary" | "verbose"; + +const SUMMARY_ENTRY_LIMIT = 10; +const SUMMARY_VALUE_LIMIT = 5; +const SUMMARY_VALUE_LENGTH_LIMIT = 60; + +const PREFIX = "react-native-css"; + +// Metro re-transforms a CSS file on every rebuild, so a file's block prints again only when its warnings change +const lastReportedByFile = new Map(); + +// Metro forwards each transform worker's stderr to the bundler's terminal, and a warning never fails the build +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) { + lastReportedByFile.delete(filename); + return; + } + + if (lastReportedByFile.get(filename) === message) { + return; + } + + lastReportedByFile.set(filename, message); + + console.warn(message); +} + +// Plain text: Metro's transform workers write to a pipe, where colour would never render +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, + ); + + // A syntax warning sends the reader to their stylesheet rather than to what React Native lacks, so it is counted apart + 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); + + 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"); +} + +function toDisplayPath(filename: string, projectRoot: string): string { + const relativePath = relative(projectRoot, filename); + + return relativePath === "" || relativePath.startsWith("..") + ? filename + : relativePath; +} + +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)` : ""; +} + +// Code-unit order rather than localeCompare, whose collation varies with the ICU build +function unique(entries: string[] | undefined): string[] { + return entries ? [...new Set(entries)].sort() : []; +} + +function stringify(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === null || typeof value !== "object") { + return String(value); + } + + return JSON.stringify(value); +} + +// An unresolved calc() is recorded as its whole lightningcss node, hundreds of characters long +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`; +}