Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/__tests__/compiler/declarations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
214 changes: 213 additions & 1 deletion src/__tests__/native/colors.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(
<>
<View testID="resolved" className="resolved" />
<View testID="unresolved" className="unresolved" />
</>,
);

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(
<>
<View testID="resolved" className="resolved" />
<View testID="unresolved" className="unresolved" />
</>,
);

expect(renderedColor("resolved")).toBe(processColor("#000"));
expect(renderedColor("unresolved")).toBe(processColor("rgb(255, 0, 0)"));
});
});

describe("currentcolor", () => {
test("currentcolor and global variables", () => {
registerCSS(`
Expand Down
92 changes: 84 additions & 8 deletions src/compiler/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2880,6 +2880,62 @@ 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);
}

/**
* 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.
*
* `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;

/**
* 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 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 Math.abs(hue) < SMALLEST_UNNAMEABLE_HUE ? hue : 0;
}

export function parseUnresolvedColor(
color: UnresolvedColor,
builder: StylesheetBuilder,
Expand All @@ -2888,27 +2944,47 @@ 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),
],
];
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.
//
// The hue is the only unbounded channel: lightningcss clamps saturation,
// 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: [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"]],
Expand Down