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`; +}