From a547588cb21ca80f0cf30b4a6a2fd8e9e3b0d33e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 15:21:23 +0300 Subject: [PATCH 1/3] fix(compiler): an unresolved alpha corrupts the colour beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightningcss hands over the channels of `rgb(… / var(--a))` as 0-255 integers, and parseUnresolvedColor multiplied them by 255 again, so `rgb(255 0 0 / var(--a))` rendered `rgba(65025, 0, 0, 0.5)`. The hsl spelling emitted `hsl(120, 50, 50, 0.5)`, which React Native rejects. The rgb channels pass through, and the hsl channels convert to the sRGB parseColor writes for the resolved spelling. A hue that is infinite, NaN or past the float32 range of the second lightningcss pass renders as hue 0, as browsers render an infinite or NaN hue. --- src/__tests__/compiler/declarations.test.tsx | 11 + src/__tests__/native/colors.test.tsx | 214 ++++++++++++++++++- src/compiler/declarations.ts | 37 +++- 3 files changed, 253 insertions(+), 9 deletions(-) diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index c2e18378..a747ce5f 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -12,6 +12,17 @@ const tests = [ ["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotateX"]], s: [1, 1] }]], ["stroke-width: 1px;", [{ d: [[1, ["strokeWidth"]]], s: [1, 1] }]], ["stroke: black;", [{ d: [["#000", ["stroke"]]], s: [1, 1] }]], + ["background-color: rgb(255 0 0 / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: rgb(100% 0% 0% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: rgb(50% 25% 10% / var(--a));", [{ d: [[[{}, "rgba", [128, 64, 26, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(0 84.2% 60.2% / var(--a));", [{ d: [[[{}, "rgba", [239, 68, 68, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(120 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(calc(NaN) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(calc(infinity) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(4294967296 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(1e20 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(-600 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], + ["background-color: hsl(1e7 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [170, 0, 255, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]], ] as const; test.each(tests)("declarations for %s", (declarations, expected) => { diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 6a8c7255..dc82e707 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,6 +1,9 @@ -import { render, screen } from "@testing-library/react-native"; +import { processColor } from "react-native"; + +import { act, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; describe("hsl", () => { test("inline", () => { @@ -136,6 +139,215 @@ describe("hsla", () => { }); }); +describe("unresolved alpha", () => { + test("rgb with number channels", () => { + registerCSS(`.my-class { + background-color: rgb(255 0 0 / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(255, 0, 0, 0.5)", + }); + }); + + test("rgb with percentage channels", () => { + registerCSS(`.my-class { + background-color: rgb(50% 25% 10% / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(128, 64, 26, 0.5)", + }); + }); + + // The resolved path compiles the same channels to `#ef4444`, and React Native + // rejects both `hsl()` carrying an alpha and `hsla()` missing one. + test("hsl resolves to the same channels as the resolved path", () => { + registerCSS(`.my-class { + background-color: hsl(0 84.2% 60.2% / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(239, 68, 68, 0.5)", + }); + }); + + // lightningcss clamps saturation, lightness and every rgb channel, so the hue + // is the only channel an out-of-range `calc()` reaches the compiler through. + // It arrives as a 32-bit float, and past 2**32 one step of that grid covers + // more than a turn, so the value no longer names an angle. Each row below is a + // different way of landing past it and they all compile to one colour. + test.each([ + "calc(NaN)", // serialized past the float range, reparses to Infinity + "calc(infinity)", // reparses saturated, at 9223372036854776000 + "calc(-infinity)", + "4294967296", // 2**32, where one step of the grid first covers a turn + "1e20", // saturates too, arriving as 9223369837831520000 + "1e38", // the same value: past the ceiling the hue is no longer carried + ])("hsl with a hue the float grid cannot name: %s", (hue) => { + registerCSS(`.my-class { + background-color: hsl(${hue} 100% 50% / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(255, 0, 0, 0.5)", + }); + }); + + // The other side of that boundary: a hue far outside [0, 360) but still on a + // part of the grid that resolves finer than a turn is reduced, never clamped. + // + // Every row has to land on a colour the clamp does NOT also produce, or it + // cannot tell reduction from clamping — a hue that reduces to 0 agrees with + // the clamp and passes either way. `3e9` is also what bounds the threshold + // from below: it sits between 2**31 and 2**32, where the float32 ULP is 256 + // and so still finer than a turn, and it arrives exactly because one + // significant digit survives any serializer. Together with the 2**32 row + // above it brackets `SMALLEST_UNNAMEABLE_HUE` to within a factor of two. + test.each([ + ["-600", "rgba(0, 255, 0, 0.5)"], + ["3e9", "rgba(0, 255, 0, 0.5)"], + ["1e7", "rgba(170, 0, 255, 0.5)"], + ])("hsl reduces a large nameable hue: %s", (hue, expected) => { + registerCSS(`.my-class { + background-color: hsl(${hue} 100% 50% / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: expected, + }); + }); + + test("light-dark carries an unresolved alpha into both schemes", () => { + registerCSS(`.my-class { + background-color: light-dark( + rgb(50% 25% 10% / var(--a, 0.5)), + hsl(120 100% 50% / var(--a, 0.5)) + ); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(128, 64, 26, 0.5)", + }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(0, 255, 0, 0.5)", + }); + }); + + afterEach(() => { + act(() => { + colorScheme.set("light"); + }); + }); +}); + +// `parseColor` compiles a fully resolved colour and `parseUnresolvedColor` +// compiles the same channels with the alpha left open. An opaque fallback makes +// the two spellings the same colour, so React Native has to read one number +// from both. +describe("unresolved alpha matches the resolved spelling", () => { + function renderedColor(id: string) { + const style: unknown = screen.getByTestId(id).props.style; + + return typeof style === "object" && + style !== null && + "backgroundColor" in style && + typeof style.backgroundColor === "string" + ? processColor(style.backgroundColor) + : undefined; + } + + const colors = [ + "rgb(255 0 0)", + "rgb(100% 0% 0%)", + "rgb(50% 25% 10%)", + "hsl(0 84.2% 60.2%)", + "hsl(120 100% 50%)", + "hsl(-600 100% 50%)", + "hsl(1e7 100% 50%)", + "hsl(calc(NaN) 100% 50%)", + "hsl(4294967296 100% 50%)", + "hsl(1e20 100% 50%)", + "hsl(1e38 100% 50%)", + ] as const; + + test.each(colors)("%s", (color) => { + registerCSS(` + .resolved { background-color: ${color}; } + .unresolved { background-color: ${color.slice(0, -1)} / var(--a, 1)); } + `); + + render( + <> + + + , + ); + + const expected = renderedColor("resolved"); + + // A colour React Native rejects reads as `undefined`, which would make the + // comparison below pass while neither spelling renders anything. + expect(typeof expected).toBe("number"); + + expect(renderedColor("unresolved")).toBe(expected); + }); + + // `calc(infinity)` stands for a family, not a special case: sampling f32 hues + // above 2**32, about one in seven resolves to something other than the red the + // compiler emits, `5e10`, `1e12`, `1.44e38` and `calc(-infinity)` among them. + // + // What makes the family unmatchable is not that lightningcss is erratic — it + // is that the distinguishing information never reaches this compiler. + // Seventeen authored hues from `1e19` to `9223372036854775807` all arrive as + // the single value `9223369837831520000`, and lightningcss's resolved path + // splits that one arriving value twelve red to five black. No function of the + // hue this compiler receives can separate inputs it receives as one number. + // + // So the compiler emits the answer that IS a function of the arriving hue and + // the divergence is pinned here rather than reproduced. This goes red if + // lightningcss stabilises, which is when the row belongs in the list above. + test("a saturated hue diverges from lightningcss's own resolution", () => { + registerCSS(` + .resolved { background-color: hsl(calc(infinity) 100% 50%); } + .unresolved { background-color: hsl(calc(infinity) 100% 50% / var(--a, 1)); } + `); + + render( + <> + + + , + ); + + expect(renderedColor("resolved")).toBe(processColor("#000")); + expect(renderedColor("unresolved")).toBe(processColor("rgb(255, 0, 0)")); + }); +}); + describe("currentcolor", () => { test("currentcolor and global variables", () => { registerCSS(` diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..022f32c0 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2924,6 +2924,19 @@ export function parseTranslateProp( return parseLength(value[prop], builder); } +// colorjs.io holds sRGB in 0-1 and rgba() takes 0-255; CSS Color 4 renders a missing component as 0 +function toRgbChannel(coordinate: number | null): number { + return Math.round((coordinate ?? 0) * 255); +} + +// The second lightningcss pass reparses a hue as a float32, whose step from 2 ** 32 spans more than a turn +const FLOAT32_HUE_LIMIT = 2 ** 32; + +// Browsers render an infinite or NaN hue as 0, and past the float32 limit a hue names no angle either +function toHueDegrees(hue: number): number { + return Math.abs(hue) < FLOAT32_HUE_LIMIT ? hue : 0; +} + export function parseUnresolvedColor( color: UnresolvedColor, builder: StylesheetBuilder, @@ -2932,27 +2945,35 @@ export function parseUnresolvedColor( ): StyleDescriptor { switch (color.type) { case "rgb": + // lightningcss hands over rgb channels as 0-255 integers, the scale rgba() takes return [ {}, "rgba", [ - round(color.r * 255), - round(color.g * 255), - round(color.b * 255), + color.r, + color.g, + color.b, parseUnparsed(color.alpha, builder, property), ], ]; - case "hsl": + case "hsl": { + // The channels are resolved here, so they convert to the sRGB parseColor writes for the same colour + const { coords } = new Color({ + space: "hsl", + coords: [toHueDegrees(color.h), color.s, color.l], + }).to("srgb"); + return [ {}, - color.type, + "rgba", [ - color.h, - color.s, - color.l, + toRgbChannel(coords[0]), + toRgbChannel(coords[1]), + toRgbChannel(coords[2]), parseUnparsed(color.alpha, builder, property), ], ]; + } case "light-dark": { const extraRule = builder.extendRule({ m: [["=", "prefers-color-scheme", "dark"]], From 401365247e2da6702928d089ea1fe4f0727f8fb9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:32:38 +0300 Subject: [PATCH 2/3] fix(compiler): fold a variable read inside a colour function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightningcss parses `hsl(0 100% 50% / var(--a))`, `rgb(… / var(--a))` and `light-dark(#123, var(--b))` as an unresolved colour whose channels are token lists, and the inliner walked `var` and `function` tokens only. A variable declared once therefore lost its declaration while a read inside a colour function kept naming it, so the alpha, or the arm, came out empty. --- src/__tests__/compiler/compiler.test.tsx | 20 ++++++++++++++++++++ src/compiler/inline-variables.ts | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..6e47ae54 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -99,6 +99,26 @@ test("preserves excluded CSS variables using the public compiler option", () => expect(rule?.v?.some(([name]) => name === "red")).toBe(false); }); +test.each([ + [ + "an hsl() alpha", + `:root { --alpha: 0.5; } .test { color: hsl(0 100% 50% / var(--alpha)); }`, + `.test { color: hsl(0 100% 50% / 0.5); }`, + ], + [ + "an rgb() alpha", + `:root { --alpha: 0.5; } .test { color: rgb(255 0 0 / var(--alpha)); }`, + `.test { color: rgb(255 0 0 / 0.5); }`, + ], + [ + "a light-dark() arm", + `:root { --dark: #654321; } .test { color: light-dark(#123456, var(--dark)); }`, + `.test { color: light-dark(#123456, #654321); }`, + ], +])("folds a variable read inside %s", (_name, css, folded) => { + expect(compile(css).stylesheet()).toStrictEqual(compile(folded).stylesheet()); +}); + test("multiple rules with same selector", () => { const compiled = compile(` .redOrGreen:hover { diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..1d94c001 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -151,6 +151,21 @@ function flattenPart( part.value.arguments = part.value.arguments.flatMap((arg) => { return flattenPart(arg, vars); }); + } else if (part.type === "unresolved-color") { + const color = part.value; + + switch (color.type) { + case "rgb": + case "hsl": + color.alpha = color.alpha.flatMap((arg) => flattenPart(arg, vars)); + break; + case "light-dark": + color.light = color.light.flatMap((arg) => flattenPart(arg, vars)); + color.dark = color.dark.flatMap((arg) => flattenPart(arg, vars)); + break; + default: + color satisfies never; + } } return part; From d91f6713ca7203e077e42f4b2f970d6d35301f15 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 15:25:30 +0300 Subject: [PATCH 3/3] test(native): check unresolved colours against what browsers compute Each colour is the value Chromium 153, Firefox 155 and WebKit 26.6 compute with an alpha of 0.5, and each is rendered four ways: the alpha read at runtime, read from a fallback, folded at compile time, and the resolved spelling. A hue that is infinite or NaN renders as hue 0 in all three browsers; lightningcss's first pass resolves the resolved spelling of one to an achromatic colour, which stays pinned. --- src/__tests__/native/colors.test.tsx | 331 +++++++++++++-------------- 1 file changed, 162 insertions(+), 169 deletions(-) diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index dc82e707..544a023e 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -139,212 +139,205 @@ describe("hsla", () => { }); }); -describe("unresolved alpha", () => { - test("rgb with number channels", () => { - registerCSS(`.my-class { - background-color: rgb(255 0 0 / var(--a, 0.5)); - }`); +// Chromium 153, Firefox 155 and WebKit 26.6 compute each colour below with an alpha of 0.5 +const BROWSER_COLORS = [ + ["rgb(255 0 0)", "rgba(255, 0, 0, 0.5)"], + ["rgb(100% 0% 0%)", "rgba(255, 0, 0, 0.5)"], + ["rgb(51 102 153)", "rgba(51, 102, 153, 0.5)"], + ["rgb(127.5 0 0)", "rgba(128, 0, 0, 0.5)"], + ["rgb(50% 25% 10%)", "rgba(128, 64, 26, 0.5)"], + ["rgb(50% 25% 75%)", "rgba(128, 64, 191, 0.5)"], + ["rgb(300 -20 0)", "rgba(255, 0, 0, 0.5)"], + ["hsl(120 50% 50%)", "rgba(64, 191, 64, 0.5)"], + ["hsl(120deg 100% 25%)", "rgba(0, 128, 0, 0.5)"], + ["hsl(0.5turn 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(3.14159rad 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(200grad 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(480 50% 50%)", "rgba(64, 191, 64, 0.5)"], + ["hsl(-120 50% 50%)", "rgba(64, 64, 191, 0.5)"], + ["hsl(-600 100% 50%)", "rgba(0, 255, 0, 0.5)"], + ["hsl(1e7 100% 50%)", "rgba(170, 0, 255, 0.5)"], + ["hsl(120 150% 50%)", "rgba(0, 255, 0, 0.5)"], + ["hsl(120 50% 120%)", "rgba(255, 255, 255, 0.5)"], + ["hsl(120 -10% 50%)", "rgba(128, 128, 128, 0.5)"], + ["hsl(0 84.2% 60.2%)", "rgba(239, 68, 68, 0.5)"], + ["hsl(33.3 47.4% 11.2%)", "rgba(42, 30, 15, 0.5)"], +] as const; + +// The same browsers render an infinite or NaN hue as hue 0 +const NON_FINITE_HUE_COLORS = [ + ["hsl(calc(infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"], + ["hsl(calc(-infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"], + ["hsl(calc(NaN) 50% 50%)", "rgba(191, 64, 64, 0.5)"], +] as const; + +function withAlpha(color: string, alpha: string) { + return `${color.slice(0, -1)} / ${alpha})`; +} + +function renderedBackground(id = testID) { + const style: unknown = screen.getByTestId(id).props.style; + + return typeof style === "object" && + style !== null && + "backgroundColor" in style && + typeof style.backgroundColor === "string" + ? processColor(style.backgroundColor) + : undefined; +} + +function renderInParent() { + render( + + + , + ); +} + +describe("a colour with an unresolved alpha renders what browsers compute", () => { + test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])( + "%s, alpha read at runtime", + (color, expected) => { + registerCSS( + `.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`, + { inlineVariables: false }, + ); - render(); - const component = screen.getByTestId(testID); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(255, 0, 0, 0.5)", - }); - }); + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); - test("rgb with percentage channels", () => { - registerCSS(`.my-class { - background-color: rgb(50% 25% 10% / var(--a, 0.5)); - }`); + test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])( + "%s, alpha read from a fallback", + (color, expected) => { + registerCSS( + `.my-class { background-color: ${withAlpha(color, "var(--a, 0.5)")}; }`, + ); - render(); - const component = screen.getByTestId(testID); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(128, 64, 26, 0.5)", - }); - }); + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); - // The resolved path compiles the same channels to `#ef4444`, and React Native - // rejects both `hsl()` carrying an alpha and `hsla()` missing one. - test("hsl resolves to the same channels as the resolved path", () => { - registerCSS(`.my-class { - background-color: hsl(0 84.2% 60.2% / var(--a, 0.5)); - }`); + test.each(BROWSER_COLORS)( + "%s, alpha folded at compile time", + (color, expected) => { + registerCSS( + `.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`, + ); - render(); - const component = screen.getByTestId(testID); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(239, 68, 68, 0.5)", - }); - }); + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); - // lightningcss clamps saturation, lightness and every rgb channel, so the hue - // is the only channel an out-of-range `calc()` reaches the compiler through. - // It arrives as a 32-bit float, and past 2**32 one step of that grid covers - // more than a turn, so the value no longer names an angle. Each row below is a - // different way of landing past it and they all compile to one colour. - test.each([ - "calc(NaN)", // serialized past the float range, reparses to Infinity - "calc(infinity)", // reparses saturated, at 9223372036854776000 - "calc(-infinity)", - "4294967296", // 2**32, where one step of the grid first covers a turn - "1e20", // saturates too, arriving as 9223369837831520000 - "1e38", // the same value: past the ceiling the hue is no longer carried - ])("hsl with a hue the float grid cannot name: %s", (hue) => { - registerCSS(`.my-class { - background-color: hsl(${hue} 100% 50% / var(--a, 0.5)); - }`); + test.each(BROWSER_COLORS)( + "%s, resolved at compile time", + (color, expected) => { + registerCSS( + `.my-class { background-color: ${withAlpha(color, "0.5")}; }`, + ); - render(); - const component = screen.getByTestId(testID); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(255, 0, 0, 0.5)", - }); - }); + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); - // The other side of that boundary: a hue far outside [0, 360) but still on a - // part of the grid that resolves finer than a turn is reduced, never clamped. - // - // Every row has to land on a colour the clamp does NOT also produce, or it - // cannot tell reduction from clamping — a hue that reduces to 0 agrees with - // the clamp and passes either way. `3e9` is also what bounds the threshold - // from below: it sits between 2**31 and 2**32, where the float32 ULP is 256 - // and so still finer than a turn, and it arrives exactly because one - // significant digit survives any serializer. Together with the 2**32 row - // above it brackets `SMALLEST_UNNAMEABLE_HUE` to within a factor of two. - test.each([ - ["-600", "rgba(0, 255, 0, 0.5)"], - ["3e9", "rgba(0, 255, 0, 0.5)"], - ["1e7", "rgba(170, 0, 255, 0.5)"], - ])("hsl reduces a large nameable hue: %s", (hue, expected) => { - registerCSS(`.my-class { - background-color: hsl(${hue} 100% 50% / var(--a, 0.5)); - }`); + test("the alpha is the variable's value on the element", () => { + registerCSS( + `.parent { --a: 0.5; } .my-class { --a: 0.25; background-color: ${withAlpha("rgb(255 0 0)", "var(--a)")}; }`, + { inlineVariables: false }, + ); - render(); - const component = screen.getByTestId(testID); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: expected, - }); + expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.25)")); }); +}); - test("light-dark carries an unresolved alpha into both schemes", () => { - registerCSS(`.my-class { - background-color: light-dark( - rgb(50% 25% 10% / var(--a, 0.5)), - hsl(120 100% 50% / var(--a, 0.5)) - ); - }`); - - render(); - const component = screen.getByTestId(testID); - - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(128, 64, 26, 0.5)", - }); +describe("a hue the second lightningcss pass reads as a float32", () => { + // Chromium and WebKit reduce 3e9 to 120deg; Firefox renders it as hue 0 + test("below 2 ** 32 the hue reduces", () => { + registerCSS( + `.my-class { background-color: ${withAlpha("hsl(3e9 100% 50%)", "var(--a, 0.5)")}; }`, + ); - act(() => { - colorScheme.set("dark"); - }); + renderInParent(); - expect(component.props.style).toStrictEqual({ - backgroundColor: "rgba(0, 255, 0, 0.5)", - }); + expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)")); }); - afterEach(() => { - act(() => { - colorScheme.set("light"); - }); - }); -}); + test.each(["4294967296", "1e20", "1e38"])( + "%s names no angle and renders as hue 0", + (hue) => { + registerCSS(` + .my-class { background-color: ${withAlpha(`hsl(${hue} 100% 50%)`, "var(--a, 0.5)")}; } + .resolved { background-color: hsl(${hue} 100% 50% / 0.5); } + `); + + render( + <> + + + , + ); + + expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.5)")); + expect(renderedBackground("resolved")).toBe(renderedBackground()); + }, + ); -// `parseColor` compiles a fully resolved colour and `parseUnresolvedColor` -// compiles the same channels with the alpha left open. An opaque fallback makes -// the two spellings the same colour, so React Native has to read one number -// from both. -describe("unresolved alpha matches the resolved spelling", () => { - function renderedColor(id: string) { - const style: unknown = screen.getByTestId(id).props.style; - - return typeof style === "object" && - style !== null && - "backgroundColor" in style && - typeof style.backgroundColor === "string" - ? processColor(style.backgroundColor) - : undefined; - } - - const colors = [ - "rgb(255 0 0)", - "rgb(100% 0% 0%)", - "rgb(50% 25% 10%)", - "hsl(0 84.2% 60.2%)", - "hsl(120 100% 50%)", - "hsl(-600 100% 50%)", - "hsl(1e7 100% 50%)", - "hsl(calc(NaN) 100% 50%)", - "hsl(4294967296 100% 50%)", - "hsl(1e20 100% 50%)", - "hsl(1e38 100% 50%)", - ] as const; - - test.each(colors)("%s", (color) => { + // lightningcss's first pass resolves an infinite hue to an achromatic colour; browsers render hue 0 + test("lightningcss resolves calc(infinity) to black where the unresolved spelling renders hue 0", () => { registerCSS(` - .resolved { background-color: ${color}; } - .unresolved { background-color: ${color.slice(0, -1)} / var(--a, 1)); } + .resolved { background-color: hsl(calc(infinity) 100% 50%); } + .my-class { background-color: ${withAlpha("hsl(calc(infinity) 100% 50%)", "var(--a, 1)")}; } `); render( <> - + , ); - const expected = renderedColor("resolved"); - - // A colour React Native rejects reads as `undefined`, which would make the - // comparison below pass while neither spelling renders anything. - expect(typeof expected).toBe("number"); + expect(renderedBackground("resolved")).toBe(processColor("#000")); + expect(renderedBackground()).toBe(processColor("rgb(255, 0, 0)")); + }); +}); - expect(renderedColor("unresolved")).toBe(expected); +describe("light-dark()", () => { + afterEach(() => { + act(() => { + colorScheme.set("light"); + }); }); - // `calc(infinity)` stands for a family, not a special case: sampling f32 hues - // above 2**32, about one in seven resolves to something other than the red the - // compiler emits, `5e10`, `1e12`, `1.44e38` and `calc(-infinity)` among them. - // - // What makes the family unmatchable is not that lightningcss is erratic — it - // is that the distinguishing information never reaches this compiler. - // Seventeen authored hues from `1e19` to `9223372036854775807` all arrive as - // the single value `9223369837831520000`, and lightningcss's resolved path - // splits that one arriving value twelve red to five black. No function of the - // hue this compiler receives can separate inputs it receives as one number. - // - // So the compiler emits the answer that IS a function of the arriving hue and - // the divergence is pinned here rather than reproduced. This goes red if - // lightningcss stabilises, which is when the row belongs in the list above. - test("a saturated hue diverges from lightningcss's own resolution", () => { + test("carries an unresolved alpha into both schemes", () => { registerCSS(` - .resolved { background-color: hsl(calc(infinity) 100% 50%); } - .unresolved { background-color: hsl(calc(infinity) 100% 50% / var(--a, 1)); } + :root { color-scheme: light dark; } + .my-class { + background-color: light-dark( + rgb(50% 25% 10% / var(--a, 0.5)), + hsl(120 100% 50% / var(--a, 0.5)) + ); + } `); - render( - <> - - - , - ); + render(); + + expect(renderedBackground()).toBe(processColor("rgba(128, 64, 26, 0.5)")); + + act(() => { + colorScheme.set("dark"); + }); - expect(renderedColor("resolved")).toBe(processColor("#000")); - expect(renderedColor("unresolved")).toBe(processColor("rgb(255, 0, 0)")); + expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)")); }); });