From 7d69ff16d552e30e631c7785672a3b1a5fc49ad8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:41:37 +0300 Subject: [PATCH 1/5] fix: stop scaling resolved rgb channels in parseUnresolvedColor lightningcss resolves the rgb channels of an `UnresolvedColor` to integers in the 0-255 range before handing them over, the percentage syntax included, so multiplying by 255 again produces impossible channels: `rgb(255 0 0 / var(--a))` compiled to `rgba(65025, 0, 0)`. React Native clamps to 255, which hides the defect while every channel is already saturated, but destroys any other colour: `rgb(50% 25% 10% / var(--a))` reached the view as `rgba(32640, 16320, 6630)` and painted white instead of brown. `parseColor` reads the same 0-255 channels and divides by 255 for colorjs.io's 0-1 sRGB space. The unresolved path emits a CSS `rgba()` string, so its channels pass straight through. --- src/__tests__/compiler/declarations.test.tsx | 3 +++ src/__tests__/native/colors.test.tsx | 28 ++++++++++++++++++++ src/compiler/declarations.ts | 9 ++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index c2e18378..ad3c512d 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -12,6 +12,9 @@ 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] }]], ] 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..4400da12 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -136,6 +136,34 @@ 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)", + }); + }); +}); + describe("currentcolor", () => { test("currentcolor and global variables", () => { registerCSS(` diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..6263228c 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2888,13 +2888,16 @@ export function parseUnresolvedColor( ): StyleDescriptor { switch (color.type) { case "rgb": + // lightningcss resolves rgb channels to integers in the 0-255 range, + // including the percentage syntax, so they are already the values + // `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), ], ]; From e160a8c3d7d780671e3d99ac9e0be37c7484fa84 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 11:19:23 +0300 Subject: [PATCH 2/5] fix: emit unresolved hsl colors as rgba `parseUnresolvedColor` emitted the hue, saturation and lightness as bare numbers, so `hsl(0 84.2% 60.2% / var(--a))` reached the view as `hsl(0, 84.19999694824219, 60.20000076293945, 0.5)`. React Native reads no percentage units on the saturation and lightness there, normalizes the whole declaration to null, and leaves the property unset. An `hsla()` spelling carrying those units is accepted, so a valid hsl form does exist, but it degrades badly under the one thing an `UnresolvedColor` guarantees: the alpha is a `var()`. An unset variable with no fallback drops the argument, which leaves `hsla()` three-argument and rejected, while `rgba()` stays valid and renders opaque. lightningcss resolves every channel of an `UnresolvedColor` and leaves only the alpha open, so the hue, saturation and lightness convert to the sRGB channels `parseColor` writes for the resolved spelling. Both spellings then compile to one colour, and a dropped alpha degrades the same way in each. --- src/__tests__/compiler/declarations.test.tsx | 2 ++ src/__tests__/native/colors.test.tsx | 15 ++++++++++ src/compiler/declarations.ts | 30 ++++++++++++++++---- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index ad3c512d..65c9c2f8 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -15,6 +15,8 @@ const tests = [ ["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] }]], ] 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 4400da12..ae2306a6 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -162,6 +162,21 @@ describe("unresolved alpha", () => { 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)", + }); + }); }); describe("currentcolor", () => { diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 6263228c..e66e1c67 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2880,6 +2880,14 @@ export function parseTranslateProp( return parseLength(value[prop], builder); } +/** + * colorjs.io holds sRGB in the 0-1 range while `rgba()` takes 0-255 channels. + * A `null` coordinate is a missing component, which CSS Color 4 treats as `0`. + */ +function toRgbChannel(coordinate: number | null): number { + return Math.round((coordinate ?? 0) * 255); +} + export function parseUnresolvedColor( color: UnresolvedColor, builder: StylesheetBuilder, @@ -2901,17 +2909,29 @@ export function parseUnresolvedColor( parseUnparsed(color.alpha, builder, property), ], ]; - case "hsl": + case "hsl": { + // An `UnresolvedColor` always leaves the alpha as a `var()`, and an unset + // variable with no fallback drops that argument. `hsla()` is rejected + // three-argument, so it cannot carry an alpha that may vanish, while + // `rgba()` stays valid and renders opaque. lightningcss resolves the hue, + // saturation and lightness, so they convert to the sRGB channels + // `parseColor` writes for the resolved spelling and share the shape above. + const { coords } = new Color({ + space: "hsl", + coords: [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 69e1ff945deba913606ea1ad85d51a72a73bdf8a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 11:19:23 +0300 Subject: [PATCH 3/5] fix: guard a non-finite hue in parseUnresolvedColor lightningcss clamps saturation, lightness and every rgb channel to their range, which leaves the hue as the one unbounded channel: it serializes a non-finite `calc()` hue as a float that reparses to `Infinity`. colorjs.io reduces a hue modulo 360, so such a hue spreads `NaN` across all three sRGB coordinates and `hsl(calc(NaN) 100% 50% / var(--a, 0.5))` reaches the view as `rgba(NaN, NaN, NaN, 0.5)`, which normalizes to null and leaves the property unset. The resolved spelling of that declaration renders red, because lightningcss folds it to a colour before `parseColor` ever runs. colorjs.io serializes a non-finite hue as `#NaNNaNNaN` rather than coercing it, so reading `coords` directly is what exposes the divergence at exactly this input. A hue that is not a real number now takes the `0` CSS Color 4 gives a missing component, which is also the hue lightningcss resolves the same declaration to. `Number.isNaN` does not cover this, so the guard tests for a finite value: the hue arrives as `Infinity`, not as `NaN`. The added parity cases assert that each unresolved spelling and its resolved twin reach React Native as one colour, which is the property both fixes establish. --- src/__tests__/compiler/declarations.test.tsx | 1 + src/__tests__/native/colors.test.tsx | 98 +++++++++++++++++++- src/compiler/declarations.ts | 10 +- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index 65c9c2f8..382b03a3 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -17,6 +17,7 @@ const tests = [ ["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] }]], ] 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 ae2306a6..cda67363 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", () => { @@ -177,6 +180,99 @@ describe("unresolved alpha", () => { backgroundColor: "rgba(239, 68, 68, 0.5)", }); }); + + // lightningcss clamps saturation, lightness and every rgb channel, so the hue + // is the only channel a non-finite `calc()` reaches the compiler through. + test("hsl with a non-finite hue", () => { + registerCSS(`.my-class { + background-color: hsl(calc(NaN) 100% 50% / var(--a, 0.5)); + }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(255, 0, 0, 0.5)", + }); + }); + + 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(calc(NaN) 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); + }); }); describe("currentcolor", () => { diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index e66e1c67..52566be2 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2916,9 +2916,17 @@ export function parseUnresolvedColor( // `rgba()` stays valid and renders opaque. lightningcss resolves the hue, // saturation and lightness, so they convert to the sRGB channels // `parseColor` writes for the resolved spelling and share the shape above. + // + // The hue is the only unbounded channel: lightningcss clamps saturation, + // lightness and every rgb channel to their range, but serializes a + // non-finite `calc()` hue as a float that reparses to `Infinity`. + // colorjs.io reduces a hue modulo 360, so such a hue spreads `NaN` across + // all three sRGB coordinates and yields a colour React Native discards. + // Per CSS Color 4 a missing component is `0`, which is also the hue + // lightningcss resolves the same declaration to when the alpha is known. const { coords } = new Color({ space: "hsl", - coords: [color.h, color.s, color.l], + coords: [Number.isFinite(color.h) ? color.h : 0, color.s, color.l], }).to("srgb"); return [ From 287cf8e2fcc1544ec3f22f8b88056b91fb5207a7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 20:09:59 +0300 Subject: [PATCH 4/5] fix: coerce a hue the float grid cannot name in parseUnresolvedColor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hue guard tested `Number.isFinite`, which only catches a hue that arrives as `Infinity`. A `calc(infinity)` hue does not: the compiler runs lightningcss twice and the second pass reparses the first pass's serialized output, where the hue saturates rather than overflowing. Measured through the compiler, `hsl(calc(infinity) 100% 50% / var(--a, 1))` arrives as `9223372036854776000`, and `hsl(1e20 ...)` and `hsl(1e38 ...)` both arrive as `9223369837831520000` — the same value, because past the ceiling the declaration's hue is not carried at all. colorjs.io reduces those modulo 360 and hands back a colour, so the compiler emitted `rgba(255, 34, 0, ...)` for the first and `rgba(255, 0, 102, ...)` for the other two, none of which the declaration asked for and none of which agrees with the resolved spelling of the same colour. The guard's subject was wrong rather than its threshold. A hue arrives as a 32-bit float, and reducing one modulo a turn only says something about the author's angle while that float still resolves finer than the turn. A float32 holds a 24-bit significand, so its ULP at `2 ** exponent` is `2 ** (exponent - 23)` and first covers a whole turn at `2 ** 32`; past there every representable neighbour lands on a different angle. `Infinity` is the same condition at the top of the range, which is why one test covers both. Such a hue now takes the `0` CSS Color 4 gives a missing component. Measured against the resolved spelling, that is also what lightningcss itself resolves `2 ** 32`, `1e10`, `1e15`, `1e20`, `1e30` and `1e38` to, so six more rows join the parity property this branch establishes, and `hsl(-600 ...)`, `hsl(720 ...)` and `hsl(1e7 ...)` are reduced as before rather than clamped. `calc(infinity)` is the one input left out. lightningcss resolves a hue that large in 32-bit floats and its answer is not a function of the hue — `1.40e38` resolves red, `1.42e38` black, `1.46e38` red again — so there is nothing to match. The compiler produces the answer that IS a function of the hue, and a test pins the divergence so it goes red if lightningcss ever stabilises. --- src/__tests__/compiler/declarations.test.tsx | 5 ++ src/__tests__/native/colors.test.tsx | 65 +++++++++++++++++++- src/compiler/declarations.ts | 40 +++++++++--- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index 382b03a3..a747ce5f 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -18,6 +18,11 @@ const tests = [ ["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 cda67363..1b3fe981 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -182,10 +182,20 @@ describe("unresolved alpha", () => { }); // lightningcss clamps saturation, lightness and every rgb channel, so the hue - // is the only channel a non-finite `calc()` reaches the compiler through. - test("hsl with a non-finite 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(calc(NaN) 100% 50% / var(--a, 0.5)); + background-color: hsl(${hue} 100% 50% / var(--a, 0.5)); }`); render(); @@ -196,6 +206,25 @@ describe("unresolved alpha", () => { }); }); + // 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. + test.each([ + ["-600", "rgba(0, 255, 0, 0.5)"], + ["720", "rgba(255, 0, 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( @@ -249,7 +278,12 @@ describe("unresolved alpha matches the resolved spelling", () => { "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) => { @@ -273,6 +307,31 @@ describe("unresolved alpha matches the resolved spelling", () => { expect(renderedColor("unresolved")).toBe(expected); }); + + // The one input the parity list above cannot hold. lightningcss resolves a hue + // this large in 32-bit floats and its answer is not a function anyone can + // match: across neighbouring inputs it alternates between red and `#000` + // (`1.40e38` red, `1.42e38` black, `1.46e38` red), and `calc(infinity)` lands + // on a black. The compiler emits the answer that IS a function of the hue — + // the same one every other unnameable hue gets — so the divergence is pinned + // here rather than reproduced. This goes red if lightningcss stabilises, which + // is when the row belongs in the list above instead. + 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", () => { diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 52566be2..287d8900 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2888,6 +2888,35 @@ function toRgbChannel(coordinate: number | null): number { return Math.round((coordinate ?? 0) * 255); } +/** + * A hue reaches this function as a 32-bit float, because the compiler runs + * lightningcss twice and the second pass reparses the first pass's serialized + * output. `2 ** 32` is where one step of that grid first covers a whole turn: a + * float32 holds a 24-bit significand, so its ULP at `2 ** exponent` is + * `2 ** (exponent - 23)`, which reaches 512 at an exponent of 32. + */ +const SMALLEST_UNNAMEABLE_HUE = 2 ** 32; + +/** + * Reducing a hue modulo a turn only says something about the author's angle + * while the arriving float still resolves finer than the turn. Past + * {@link SMALLEST_UNNAMEABLE_HUE} every representable neighbour lands on a + * different angle, so the reduction reports the float grid rather than the + * declaration, and the value is in practice a range limit rather than a hue — + * `hsl(1e20 …)` and `hsl(1e38 …)` both arrive as `9223369837831520000`, and + * `calc(infinity)` as `9223372036854776000`. `Infinity`, which is how a + * `calc(NaN)` hue arrives, is the same condition at the top of the range. + * + * CSS Color 4 makes a missing component `0`, so an unnameable hue takes `0`. + * That is also what lightningcss's own resolved path produces for such a hue, + * with the exception recorded in `src/__tests__/native/colors.test.tsx`. + */ +function toHueDegrees(hue: number): number { + return Number.isFinite(hue) && Math.abs(hue) < SMALLEST_UNNAMEABLE_HUE + ? hue + : 0; +} + export function parseUnresolvedColor( color: UnresolvedColor, builder: StylesheetBuilder, @@ -2918,15 +2947,12 @@ export function parseUnresolvedColor( // `parseColor` writes for the resolved spelling and share the shape above. // // The hue is the only unbounded channel: lightningcss clamps saturation, - // lightness and every rgb channel to their range, but serializes a - // non-finite `calc()` hue as a float that reparses to `Infinity`. - // colorjs.io reduces a hue modulo 360, so such a hue spreads `NaN` across - // all three sRGB coordinates and yields a colour React Native discards. - // Per CSS Color 4 a missing component is `0`, which is also the hue - // lightningcss resolves the same declaration to when the alpha is known. + // lightness and every rgb channel to their range, so the hue is the one + // place an out-of-range `calc()` reaches this function. `toHueDegrees` + // decides which arriving floats still name an angle. const { coords } = new Color({ space: "hsl", - coords: [Number.isFinite(color.h) ? color.h : 0, color.s, color.l], + coords: [toHueDegrees(color.h), color.s, color.l], }).to("srgb"); return [ From 8c5c581054ba72f79ee1336ccfedbca5dea23520 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:31:29 +0300 Subject: [PATCH 5/5] fix: correct the measurements the hue guard's comments assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime behaviour is unchanged. Three claims that shipped with the guard were refutable, and the residual's justification was the weakest of them. `calc(infinity)` was described as the one input left out. It is a family: sampling f32 hues above `2 ** 32`, about one in seven resolves to something other than the red the compiler emits — `5e10`, `1e12`, `1e18`, `1.44e38` and `calc(-infinity)` among them. The commit that added the guard cited three of these as evidence without noticing they were the same divergence class. The reason given for leaving that residual open — that lightningcss's answer is not a function of the hue — is refutable in one command. It is deterministic. The real reason is stronger. Seventeen authored hues from `1e19` to `9223372036854775807` all reach the compiler as the single value `9223369837831520000`, and lightningcss's resolved path splits that one arriving value twelve red to five black. The information separating them is destroyed before the compiler sees it, so no function of the arriving hue can reproduce the split — not because lightningcss is erratic, but because the compiler is handed one number for seventeen inputs. The pass attribution was also wrong. A visitor is what materialises the AST into JavaScript and back, and the hue saturates to i64 on that round trip. Pass one's declaration visitor saturates `1e19` through `1e38`, serializing all of them as `9223370000000000000`; pass two's rule visitor saturates `calc(infinity)`, which pass one leaves at the float32 maximum `3.40282e38`. Removing the rule visitor leaves `calc(infinity)` arriving unsaturated, which is how that split was measured. Below the threshold the binding wall is lightningcss's six-significant-digit serializer, not the float32 ULP grid, and it bites from about `1e6` — far below `2 ** 32`. `1234567` arrives as `1234570`, `12345678` as `12345700`, `123456789` as `123457000`. Sampling forty full-precision hues per decade, the arriving hue differs from the authored one in 38/40 of `[1e6, 1e7)` and 40/40 of every decade above. So the claim that the arriving float still resolves finer than the turn below the threshold was false over three decades of that range. The guard's own effect was miscounted too. It turns twelve assertions across the two files from red to green, three of them parity rows — `hsl(4294967296 …)`, `hsl(1e20 …)` and `hsl(1e38 …)`. The earlier "six more rows" counted hues measured to resolve red, half of which are not rows in the parity list at all. Two test gaps close with it. The tests bounded the constant from above and barely from below: every value from `10000001` to `4294969856` left all 55 assertions green, a 429-fold interval that `2 ** 31` sat inside. The `720` row was the reason — 720 reduces to 0, the same answer the clamp gives, so it survived a mutation that coerced every hue while both its siblings went red. Replacing it with `3e9` closes both gaps: `3e9` reduces to 120°, which the clamp does not produce, and it sits between `2 ** 31` and `2 ** 32` where the ULP is 256 and so still finer than a turn. The green window is now `[3000000001, 4294969856]`, a factor of 1.43, and `2 ** 31` is red. `3e9` also arrives exactly — one significant digit survives any serializer — so the pin does not depend on the loss described above. `Number.isFinite(hue) &&` was dead. `Math.abs(NaN) < x` and `Math.abs(±Infinity) < x` are both `false`, so the clause could not change a result; removing it leaves all 55 assertions green. The comment now says why no finiteness test is needed rather than carrying one that does nothing. Full suite unchanged: `2 failed, 4 skipped, 53 passed, 55 of 59 total` and `3 failed, 21 skipped, 1084 passed, 1108 total`, the 3 being the two `src/__tests__/babel/*` suites that fail at every ref on Windows. `yarn typecheck` and `yarn lint` exit 0. --- src/__tests__/native/colors.test.tsx | 32 +++++++++++++++------ src/compiler/declarations.ts | 43 ++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 1b3fe981..dc82e707 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -208,9 +208,17 @@ describe("unresolved alpha", () => { // 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)"], - ["720", "rgba(255, 0, 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 { @@ -308,14 +316,20 @@ describe("unresolved alpha matches the resolved spelling", () => { expect(renderedColor("unresolved")).toBe(expected); }); - // The one input the parity list above cannot hold. lightningcss resolves a hue - // this large in 32-bit floats and its answer is not a function anyone can - // match: across neighbouring inputs it alternates between red and `#000` - // (`1.40e38` red, `1.42e38` black, `1.46e38` red), and `calc(infinity)` lands - // on a black. The compiler emits the answer that IS a function of the hue — - // the same one every other unnameable hue gets — so the divergence is pinned - // here rather than reproduced. This goes red if lightningcss stabilises, which - // is when the row belongs in the list above instead. + // `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%); } diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 287d8900..8a89f654 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2894,27 +2894,46 @@ function toRgbChannel(coordinate: number | null): number { * output. `2 ** 32` is where one step of that grid first covers a whole turn: a * float32 holds a 24-bit significand, so its ULP at `2 ** exponent` is * `2 ** (exponent - 23)`, which reaches 512 at an exponent of 32. + * + * `src/__tests__/native/colors.test.tsx` brackets this constant rather than + * pinning it. A hue between `2 ** 31` and `2 ** 32` must still reduce and + * `2 ** 32` itself must not, so any constant between those two passes. The + * window is about a factor of two wide because the derivation above only + * resolves to a power of two — the ULP steps from 256 straight to 512, and no + * authored hue can land between them. */ const SMALLEST_UNNAMEABLE_HUE = 2 ** 32; /** - * Reducing a hue modulo a turn only says something about the author's angle - * while the arriving float still resolves finer than the turn. Past - * {@link SMALLEST_UNNAMEABLE_HUE} every representable neighbour lands on a - * different angle, so the reduction reports the float grid rather than the - * declaration, and the value is in practice a range limit rather than a hue — - * `hsl(1e20 …)` and `hsl(1e38 …)` both arrive as `9223369837831520000`, and - * `calc(infinity)` as `9223372036854776000`. `Infinity`, which is how a + * Past {@link SMALLEST_UNNAMEABLE_HUE} every representable neighbour lands on a + * different angle, so reducing the arriving float modulo a turn reports the + * float grid rather than the declaration, and the value is a range limit rather + * than a hue. + * + * Both lightningcss passes contribute, and they saturate different inputs. A + * visitor is what materialises the AST into JavaScript and back, and the hue + * saturates to i64 on that round trip: pass one's declaration visitor saturates + * `1e19` through `1e38`, serializing all of them as `9223370000000000000`, + * while pass two's rule visitor saturates `calc(infinity)`, which pass one + * leaves at the float32 maximum `3.40282e38`. They arrive here as + * `9223369837831520000` and `9223372036854776000`. `Infinity`, which is how a * `calc(NaN)` hue arrives, is the same condition at the top of the range. * + * This threshold is about where a hue stops naming an angle, not about where it + * stops being exact. lightningcss's serializer keeps six significant digits, so + * from about `1e6` the arriving float already names a different angle than the + * author wrote — `12345678` arrives as `12345700`, `123456789` as `123457000` — + * and those hues are still reduced, from a number the serializer chose. That + * loss is upstream of this function and no threshold here recovers it. + * * CSS Color 4 makes a missing component `0`, so an unnameable hue takes `0`. - * That is also what lightningcss's own resolved path produces for such a hue, - * with the exception recorded in `src/__tests__/native/colors.test.tsx`. + * That is also what lightningcss's own resolved path produces for most such + * hues, with the divergence recorded in `src/__tests__/native/colors.test.tsx`. + * `Math.abs` covers `NaN` and both infinities on its own — every comparison + * against them is `false` — so a separate finiteness test would be dead code. */ function toHueDegrees(hue: number): number { - return Number.isFinite(hue) && Math.abs(hue) < SMALLEST_UNNAMEABLE_HUE - ? hue - : 0; + return Math.abs(hue) < SMALLEST_UNNAMEABLE_HUE ? hue : 0; } export function parseUnresolvedColor(