diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx
index b08e24c3..b3a5e520 100644
--- a/src/__tests__/compiler/compiler.test.tsx
+++ b/src/__tests__/compiler/compiler.test.tsx
@@ -1,4 +1,8 @@
-import { compile } from "react-native-css/compiler";
+import {
+ compile,
+ type StyleDeclaration,
+ type StyleDescriptor,
+} from "react-native-css/compiler";
test("hello world", () => {
const compiled = compile(`
@@ -356,3 +360,609 @@ test("simplifies rem", () => {
],
});
});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 compute each keyword below as css-cascade-5 §7.3 defines it
+describe("CSS-wide keywords", () => {
+ const stylesheetFor = (value: string) =>
+ compile(`.child { color: ${value}; }`).stylesheet();
+
+ test("color: inherit compiles to the inherited-color lookup and publishes it", () => {
+ expect(stylesheetFor("inherit")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit and color: currentcolor compile alike", () => {
+ // currentColor on `color` is `inherit` (css-color-3 §4.4)
+ expect(stylesheetFor("inherit")).toStrictEqual(
+ stylesheetFor("currentcolor"),
+ );
+ });
+
+ test("color: currentcolor compiles to the inherited-color lookup and publishes it", () => {
+ expect(stylesheetFor("currentcolor")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("a colour of its own publishes --__rn-css-color to descendants", () => {
+ expect(stylesheetFor("red")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [{ color: "#f00" }],
+ v: [["__rn-css-color", "#f00"]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test.each(["unset", "revert", "revert-layer"])(
+ "color: %s compiles like color: inherit",
+ (keyword) => {
+ // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3), and with no user-agent or user origin `revert` is `unset` (§7.3.4)
+ expect(stylesheetFor(keyword)).toStrictEqual(stylesheetFor("inherit"));
+ },
+ );
+
+ test("color: initial compiles to the literal the runtime clears with", () => {
+ expect(stylesheetFor("initial")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [["unset", "color"]],
+ v: [["__rn-css-color", "unset"]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ const otherProperties = [
+ ["background-color", "backgroundColor"],
+ ["border-color", "borderColor"],
+ ["font-size", "fontSize"],
+ ["opacity", "opacity"],
+ ] as const;
+
+ test.each(otherProperties)("%s: inherit is dropped", (property) => {
+ // Only `color` and custom properties carry a value to descendants here
+ expect(
+ compile(`.child { ${property}: inherit; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test.each(
+ otherProperties.flatMap(([property, nativeProperty]) =>
+ (["initial", "unset", "revert", "revert-layer"] as const).map(
+ (keyword) => [property, keyword, nativeProperty] as const,
+ ),
+ ),
+ )(
+ "%s: %s compiles to the literal the runtime clears with",
+ (property, keyword, nativeProperty) => {
+ expect(
+ declarationsFor(`.child { ${property}: ${keyword}; }`),
+ ).toStrictEqual([["unset", nativeProperty]]);
+ },
+ );
+
+ test.each([
+ ["box-shadow", "boxShadow"],
+ ["text-shadow", "textShadow"],
+ ] as const)(
+ "%s: initial hands the literal to the shorthand's runtime parser",
+ (property, nativeProperty) => {
+ expect(declarationsFor(`.child { ${property}: initial; }`)).toStrictEqual(
+ [[[{}, nativeProperty, "unset", 1], nativeProperty, 1]],
+ );
+ },
+ );
+
+ test("box-shadow: inherit is dropped rather than handed to the shorthand", () => {
+ expect(
+ compile(`.child { box-shadow: inherit; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test.each([
+ ["object-fit", "initial", ["contentFit"]],
+ ["object-position", "revert", ["contentPosition"]],
+ ["corner-shape", "unset", "borderCurve"],
+ ["font-variant", "revert-layer", "fontVariant"],
+ ["-webkit-line-clamp", "initial", ["numberOfLines"]],
+ ["outline-offset", "initial", "outlineOffset"],
+ ] as const)(
+ "%s: %s compiles to the literal the runtime clears with",
+ (property, keyword, nativeProperty) => {
+ expect(
+ declarationsFor(`.child { ${property}: ${keyword}; }`),
+ ).toStrictEqual([["unset", nativeProperty]]);
+ },
+ );
+
+ test.each(["object-fit", "corner-shape"])(
+ "%s: inherit is dropped with a warning naming it",
+ (property) => {
+ const compiled = compile(`.child { ${property}: inherit; }`);
+
+ expect(compiled.stylesheet()).toStrictEqual({});
+ expect(compiled.warnings()).toStrictEqual({
+ values: { [property]: ["inherit"] },
+ });
+ },
+ );
+
+ test.each(["INHERIT", "1px 1px INHERIT"])(
+ "box-shadow: %s is reported as written",
+ (value) => {
+ expect(
+ compile(`.child { box-shadow: ${value}; }`).warnings(),
+ ).toStrictEqual({ values: { "box-shadow": ["INHERIT"] } });
+ },
+ );
+
+ test.each([
+ ["color", "color-mix(in srgb, inherit, blue)"],
+ ["color", "light-dark(inherit, blue)"],
+ ["color", "light-dark(blue, inherit)"],
+ ["color", "rgb(0 0 0 / inherit)"],
+ ["filter", "blur(inherit)"],
+ ["transform", "translateX(inherit)"],
+ ["object-position", "left inherit"],
+ ["corner-shape", "round inherit"],
+ ["font-variant", "small-caps inherit"],
+ ["box-shadow", "1px 1px light-dark(blue, inherit)"],
+ ["box-shadow", "1px 1px INHERIT"],
+ ])(
+ "%s: %s is dropped, as a CSS-wide keyword is only ever a whole value",
+ (property, value) => {
+ const compiled = compile(`.child { ${property}: ${value}; }`);
+
+ expect(compiled.stylesheet()).toStrictEqual({});
+ expect(compiled.warnings()).toStrictEqual({
+ values: {
+ [property]: [value.includes("INHERIT") ? "INHERIT" : "inherit"],
+ },
+ });
+ },
+ );
+
+ test.each([
+ ["inherit", [{}, "var", "__rn-css-color"]],
+ ["INITIAL", "unset"],
+ ] as const)(
+ "color: var(--missing, %s) falls back to what the keyword resolves to",
+ (keyword, fallback) => {
+ // Substituted, a fallback is the declaration's whole value (css-variables-1 §3)
+ expect(
+ declarationsFor(`.child { color: var(--missing, ${keyword}); }`),
+ ).toStrictEqual([[[{}, "var", ["missing", fallback], 1], "color", 1]]);
+ },
+ );
+
+ test("a whole-value var() fallback that is a var() passes its keyword fallback through", () => {
+ expect(
+ declarationsFor(`.child { color: var(--a, var(--b, inherit)); }`),
+ ).toStrictEqual([
+ [
+ [
+ {},
+ "var",
+ ["a", [{}, "var", ["b", [{}, "var", "__rn-css-color"]], 1]],
+ 1,
+ ],
+ "color",
+ 1,
+ ],
+ ]);
+ });
+
+ test.each([
+ ["padding-top", "initial", "paddingTop", "unset"],
+ ["padding-top", "4px", "paddingTop", 4],
+ ["color", "inherit", "color", [{}, "var", "__rn-css-color"]],
+ ] as const)(
+ "%s: env(safe-area-inset-top, %s) falls back to what the value resolves to there",
+ (property, value, nativeProperty, fallback) => {
+ expect(
+ declarationsFor(
+ `.child { ${property}: env(safe-area-inset-top, ${value}); }`,
+ ),
+ ).toStrictEqual([
+ [
+ [{}, "var", ["react-native-css-safe-area-inset-top", fallback], 1],
+ nativeProperty,
+ 1,
+ ],
+ ]);
+ },
+ );
+
+ test.each([
+ [
+ "width",
+ "calc(var(--missing, initial) + 1px)",
+ "calc(var(--missing) + 1px)",
+ "initial",
+ ],
+ [
+ "color",
+ "color-mix(in srgb, var(--missing, inherit), blue)",
+ "color-mix(in srgb, var(--missing), blue)",
+ "inherit",
+ ],
+ [
+ "transform",
+ "translateX(var(--missing, initial))",
+ "translateX(var(--missing))",
+ "initial",
+ ],
+ [
+ "box-shadow",
+ "1px 1px var(--missing, INHERIT)",
+ "1px 1px var(--missing)",
+ "INHERIT",
+ ],
+ ["width", "var(--missing, 10px inherit)", "var(--missing)", "inherit"],
+ [
+ "padding-top",
+ "calc(env(safe-area-inset-top, initial) + 1px)",
+ "calc(env(safe-area-inset-top) + 1px)",
+ "initial",
+ ],
+ [
+ "transform",
+ "translateY(env(safe-area-inset-top, initial))",
+ "translateY(env(safe-area-inset-top))",
+ "initial",
+ ],
+ ])(
+ "%s: %s drops the fallback, as a CSS-wide keyword leaves it invalid there",
+ (property, value, withoutFallback, keyword) => {
+ // Substituted inside a longer value, a CSS-wide keyword is invalid (css-values-4 §2.1)
+ const compiled = compile(`.child { ${property}: ${value}; }`);
+
+ expect(compiled.stylesheet()).toStrictEqual(
+ compile(`.child { ${property}: ${withoutFallback}; }`).stylesheet(),
+ );
+ expect(compiled.warnings()).toStrictEqual({
+ values: { [property]: [keyword] },
+ });
+ },
+ );
+
+ // A second declaration keeps `--brand` from being inlined, so the keyword reaches the compiler on the custom property
+ const uninlinedCustomProperty = (value: string) =>
+ `.child { --brand: ${value}; color: var(--brand); }
+ .other { --brand: ${value}; }`;
+
+ test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])(
+ "--brand: %s publishes nothing, so a read finds the ancestor's value",
+ (keyword) => {
+ const css = uninlinedCustomProperty(keyword);
+
+ expect(publishedVariable(css, "brand")).toEqual([]);
+ expect(compile(css).warnings()).toStrictEqual({});
+ },
+ );
+
+ test("--brand: initial is dropped with a warning", () => {
+ const css = uninlinedCustomProperty("initial");
+
+ expect(publishedVariable(css, "brand")).toEqual([]);
+ expect(compile(css).warnings()).toStrictEqual({
+ values: { "--brand": ["initial", "initial"] },
+ });
+ });
+
+ test("--brand: 1px INHERIT stays declared, and its keyword is dropped with a warning", () => {
+ const css = uninlinedCustomProperty("1px INHERIT");
+
+ expect(publishedVariable(css, "brand")).toEqual([1, 1]);
+ expect(compile(css).warnings()).toStrictEqual({
+ values: { "--brand": ["INHERIT", "INHERIT"] },
+ });
+ });
+
+ test("a comment beside a keyword leaves the keyword the whole value", () => {
+ expect(stylesheetFor("/* reset */ inherit")).toStrictEqual(
+ stylesheetFor("inherit"),
+ );
+ expect(
+ declarationsFor(
+ `.child { color: var(--missing, /* reset */ inherit /* here */); }`,
+ ),
+ ).toStrictEqual(
+ declarationsFor(`.child { color: var(--missing, inherit); }`),
+ );
+ expect(
+ publishedVariable(
+ uninlinedCustomProperty("/* reset */ inherit"),
+ "brand",
+ ),
+ ).toEqual([]);
+ });
+
+ test.each(["currentcolor", "currentColor"])(
+ "--brand: %s compiles to the inherited-color lookup",
+ (spelling) => {
+ // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold
+ expect(
+ publishedVariable(uninlinedCustomProperty(spelling), "brand"),
+ ).toEqual([
+ [{}, "var", "__rn-css-color"],
+ [{}, "var", "__rn-css-color"],
+ ]);
+ },
+ );
+
+ test("a custom property declared once is inlined, so its keyword resolves on color", () => {
+ expect(
+ compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])(
+ "color: %s compiles like its lowercase spelling",
+ (spelling) => {
+ // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded
+ expect(stylesheetFor(spelling)).toStrictEqual(
+ stylesheetFor(spelling.toLowerCase()),
+ );
+ },
+ );
+
+ test("color: currentColor compiles to the inherited-color lookup", () => {
+ expect(stylesheetFor("currentColor")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("border-color: currentcolor compiles to the same lookup", () => {
+ expect(
+ compile(`.child { border-color: currentcolor; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "borderColor", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit !important keeps the important specificity", () => {
+ expect(stylesheetFor("inherit !important")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit inside a media query keeps the condition", () => {
+ expect(
+ compile(
+ `@media (min-width: 100px) { .child { color: inherit; } }`,
+ ).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [2, 1],
+ m: [[">=", "width", 100]],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit inside :hover keeps the pseudo-class condition", () => {
+ expect(
+ compile(`.child:hover { color: inherit; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 2],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]],
+ p: { h: 1 },
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit on ::placeholder targets placeholderTextColor", () => {
+ expect(
+ declarationsFor(`.child::placeholder { color: inherit; }`),
+ ).toStrictEqual([
+ [[{}, "var", "__rn-css-color"], ["placeholderTextColor"], 1],
+ ]);
+ });
+
+ test.each(["placeholder", "selection"])(
+ "a ::%s rule publishes no colour, and keeps its other custom properties",
+ (pseudoElement) => {
+ const css = `.child::${pseudoElement} { --brand: red; color: var(--brand); } .other { --brand: blue; }`;
+
+ expect(publishedInheritedColors(css)).toEqual([]);
+ expect(publishedVariable(css, "brand")).toEqual(["red", "blue"]);
+ },
+ );
+});
+
+function declarationsFor(css: string): StyleDeclaration[] {
+ return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) =>
+ ruleSet.flatMap((rule) => rule.d ?? []),
+ );
+}
+
+function publishedVariable(css: string, name: string): StyleDescriptor[] {
+ return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) =>
+ ruleSet.flatMap((rule) =>
+ (rule.v ?? [])
+ .filter(([varName]) => varName === name)
+ .map(([, value]) => value),
+ ),
+ );
+}
+
+function publishedInheritedColors(css: string): StyleDescriptor[] {
+ return publishedVariable(css, "__rn-css-color");
+}
+
+describe("the inherited-color variable is never a lookup into itself", () => {
+ // Each reads the inherited colour somewhere in its descriptor, so it publishes the bare lookup, which the runtime answers with what the element inherited
+ const colorsThatReadTheInheritedColor = [
+ "inherit",
+ "unset",
+ "currentcolor",
+ "var(--missing, inherit)",
+ "var(--missing, unset)",
+ "var(--missing, currentcolor)",
+ "color-mix(in srgb, currentcolor, blue)",
+ "rgb(from currentcolor r g b)",
+ "light-dark(currentcolor, blue)",
+ ];
+
+ test.each(colorsThatReadTheInheritedColor)(
+ "color: %s publishes the bare lookup",
+ (value) => {
+ const published = publishedInheritedColors(`.child { color: ${value}; }`);
+
+ expect(new Set(published.map((entry) => JSON.stringify(entry)))).toEqual(
+ new Set([JSON.stringify([{}, "var", "__rn-css-color"])]),
+ );
+ },
+ );
+
+ test.each([
+ ["red", "#f00"],
+ ["#00f", "#00f"],
+ ["rgb(1 2 3)", "#010203"],
+ ["color-mix(in srgb, red, blue)", "#800080"],
+ ["oklch(0.7 0.1 200)", "#40b1b7"],
+ ])("color: %s still publishes its own resolved value", (value, expected) => {
+ expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([
+ expected,
+ ]);
+ });
+
+ test.each<[value: string, published: StyleDescriptor[]]>([
+ ["var(--brand)", [[{}, "var", "brand", 1]]],
+ ["var(--brand, red)", [[{}, "var", ["brand", "red"], 1]]],
+ [
+ "color-mix(in srgb, var(--brand), blue)",
+ [
+ [
+ {},
+ "colorMix",
+ ["srgb", [{}, "var", "brand", 1], undefined, "blue", undefined],
+ ],
+ ],
+ ],
+ ])(
+ "color: %s names a variable that is not the inherited one, so it publishes",
+ (value, published) => {
+ expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual(
+ published,
+ );
+ },
+ );
+
+ test("color: light-dark() emits no rule twice", () => {
+ // Parsing light-dark() adds its dark rule as a side effect, so the colour is parsed once for the declaration and its publish
+ const rules = (
+ compile(`.child { color: light-dark(red, blue); }`).stylesheet().s ?? []
+ ).flatMap(([, ruleSet]) => ruleSet.map((rule) => JSON.stringify(rule)));
+
+ expect(new Set(rules).size).toBe(rules.length);
+ });
+});
diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx
index 6a8c7255..0ea33475 100644
--- a/src/__tests__/native/colors.test.tsx
+++ b/src/__tests__/native/colors.test.tsx
@@ -1,4 +1,5 @@
-import { render, screen } from "@testing-library/react-native";
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
import { View } from "react-native-css/components/View";
import { registerCSS, testID } from "react-native-css/jest";
@@ -165,3 +166,680 @@ describe("currentcolor", () => {
});
});
});
+
+describe("inherit", () => {
+ test("color: inherit resolves to the parent's color", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit on a Text reads its parent's color", () => {
+ registerCSS(`
+ .button { color: white; }
+ .label { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("label").props.style).toStrictEqual({
+ color: "#fff",
+ });
+ });
+
+ test("inherit chains through an inheriting ancestor", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: inherit; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("inherit follows the nearest colored ancestor", () => {
+ registerCSS(`
+ .outer { color: red; }
+ .inner { color: blue; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ });
+
+ test("color: unset inherits the parent's color", () => {
+ // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3)
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: unset; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test.each(["UNSET", "INHERIT", "Inherit"])(
+ "color: %s is case-folded and inherits",
+ (spelling) => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: ${spelling}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("color: INITIAL clears an earlier rule's color", () => {
+ // React Native's default text color stands in for the initial value
+ registerCSS(`
+ .parent { color: red; }
+ .earlier { color: green; }
+ .child { color: INITIAL; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: undefined,
+ });
+ });
+
+ test("a descendant override restarts the chain", () => {
+ registerCSS(`
+ .red { color: red; }
+ .blue { color: blue; }
+ .inherit { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("first").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("second").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ });
+
+ test("color: inherit under a media query", () => {
+ registerCSS(`
+ .parent { color: red; }
+ @media (min-width: 1px) { .child { color: inherit; } }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit under :hover", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: blue; }
+ .child:hover { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const child = screen.getByTestId("child");
+ expect(child.props.style).toStrictEqual({ color: "#00f" });
+
+ fireEvent(child, "hoverIn", {});
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit !important beats a normal color on the same element", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit !important; }
+ .override { color: blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit on ::placeholder reads the parent's color", () => {
+ // React Native has no default input colour to inherit, so this matches Tailwind's preflight `input { color: inherit }`
+ registerCSS(`
+ .parent { color: red; }
+ .child::placeholder { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props).toStrictEqual({
+ children: undefined,
+ placeholderTextColor: "#f00",
+ style: {},
+ testID: "child",
+ });
+ });
+
+ test.each(["inherit", "currentcolor"])(
+ "color: %s on ::placeholder reads the element's own color",
+ (keyword) => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: green; }
+ .child::placeholder { color: ${keyword}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props).toStrictEqual({
+ children: undefined,
+ placeholderTextColor: "#008000",
+ style: { color: "#008000" },
+ testID: "child",
+ });
+ },
+ );
+
+ test("color: inherit with no colored ancestor reads the root's label color", () => {
+ registerCSS(`.child { color: inherit; }`);
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: { semantic: ["label", "labelColor"] },
+ });
+ });
+
+ test("inherit resolves an ancestor color from an inlined variable", () => {
+ registerCSS(`
+ .parent { --brand: #ff0000; color: var(--brand); }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("parent").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("inherit resolves an ancestor color from a variable that is not inlined", () => {
+ // A second declaration keeps `--brand` from being inlined
+ registerCSS(`
+ .parent { --brand: #ff0000; color: var(--brand); }
+ .child { --brand: #0000ff; color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const parentColor = screen.getByTestId("parent").props.style.color;
+
+ expect(parentColor).toBeDefined();
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: parentColor,
+ });
+ });
+
+ test("color: var(--brand) with --brand: inherit resolves when the variable is inlined", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { --brand: inherit; color: var(--brand); }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test.each([
+ ["currentcolor", "inlined"],
+ ["currentcolor", "uninlined"],
+ ["currentColor", "inlined"],
+ ["currentColor", "uninlined"],
+ ] as const)(
+ "color: var(--brand) with --brand: %s resolves on the %s route",
+ (spelling, route) => {
+ // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold
+ const secondDefinition =
+ route === "uninlined" ? `.other { --brand: ${spelling}; }` : "";
+
+ registerCSS(`
+ .parent { color: red; }
+ .child { --brand: ${spelling}; color: var(--brand); }
+ ${secondDefinition}
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("color: inherit alongside a box-shadow leaves no placeholder in the style", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; box-shadow: 1px 1px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("color: currentcolor alongside a box-shadow leaves no placeholder either", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: currentcolor; box-shadow: 1px 1px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("color: inherit alongside a text-shadow leaves no placeholder either", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; text-shadow: 1px 1px 2px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ textShadowColor: "#00f",
+ textShadowOffset: { width: 1, height: 1 },
+ textShadowRadius: 2,
+ });
+ });
+
+ test.each(["inherit", "unset", "currentcolor", "revert", "revert-layer"])(
+ "color: %s over an earlier rule hands its subtree the parent's color",
+ (keyword) => {
+ // With no user-agent or user origin, `revert` is `unset` (css-cascade-5 §7.3.4)
+ registerCSS(`
+ .parent { color: red; }
+ .earlier { color: green; }
+ .mid { color: ${keyword}; border-color: currentcolor; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toStrictEqual({
+ color: "#f00",
+ borderColor: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("a ::placeholder color leaves the element's own color", () => {
+ registerCSS(`
+ .parent { color: blue; }
+ .child { border-color: currentcolor; }
+ .child::placeholder { color: red; }
+ .grandchild { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ const child = screen.getByTestId("child");
+
+ expect(child.props.placeholderTextColor).toBe("#f00");
+ expect(child.props.style).toStrictEqual({ borderColor: "#00f" });
+ expect(screen.getByTestId("grandchild").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ });
+
+ test.each(["color-mix(in srgb, inherit, blue)", "light-dark(inherit, blue)"])(
+ "color: %s leaves an earlier rule's color",
+ (value) => {
+ // A CSS-wide keyword is only ever a whole value, so this declaration is invalid (css-values-4 §2.1)
+ registerCSS(`
+ .parent { color: red; }
+ .earlier { color: green; }
+ .child { color: ${value}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#008000",
+ });
+ },
+ );
+
+ test.each([
+ ["var(--missing, inherit)", "#f00"],
+ ["var(--missing, initial)", undefined],
+ ["var(--missing, var(--also-missing, inherit))", "#f00"],
+ ] as const)(
+ "color: %s applies the keyword over an earlier rule",
+ (value, expectedColor) => {
+ registerCSS(`
+ .parent { color: red; }
+ .earlier { color: green; }
+ .child { color: ${value}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: expectedColor,
+ });
+ },
+ );
+
+ test("a light-dark() ancestor's color is inherited by a descendant", () => {
+ registerCSS(`
+ .parent { color: light-dark(red, blue); }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("parent").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+});
+
+const colorsThatReadTheInheritedColor: [css: string, midColor: string][] = [
+ ["inherit", "#f00"],
+ ["unset", "#f00"],
+ ["currentcolor", "#f00"],
+ ["var(--missing, inherit)", "#f00"],
+ ["var(--missing, unset)", "#f00"],
+ ["var(--missing, currentcolor)", "#f00"],
+ ["light-dark(currentcolor, blue)", "#f00"],
+ ["color-mix(in srgb, currentcolor, blue)", "rgba(127.5, 0, 127.5, 1)"],
+];
+
+describe("a color that reads the inherited color", () => {
+ test.each(colorsThatReadTheInheritedColor)(
+ "mid { color: %s } resolves against its parent",
+ (midColorValue, expectedMidColor) => {
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: ${midColorValue}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toStrictEqual({
+ color: expectedMidColor,
+ });
+ },
+ );
+
+ test.each(
+ colorsThatReadTheInheritedColor.filter(
+ ([, midColor]) => midColor === "#f00",
+ ),
+ )("a child of mid { color: %s } inherits the same color", (midColorValue) => {
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: ${midColorValue}; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 agree on each case: currentcolor is the inherited colour only inside `color` itself
+describe("currentcolor inside color reads the inherited colour, and nowhere else", () => {
+ test.each([
+ [
+ "box-shadow",
+ "box-shadow: 0 0 1px currentcolor;",
+ {
+ boxShadow: [
+ {
+ offsetX: 0,
+ offsetY: 0,
+ blurRadius: 1,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ },
+ ],
+ [
+ "text-shadow",
+ "text-shadow: 1px 1px 1px currentcolor;",
+ {
+ textShadowColor: "#00f",
+ textShadowOffset: { width: 1, height: 1 },
+ textShadowRadius: 1,
+ },
+ ],
+ ] as const)(
+ "%s reads the element's own colour",
+ (_property, declaration, shadowStyle) => {
+ registerCSS(`
+ .parent { color: #ff0000; }
+ .child { color: #0000ff; ${declaration} }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#00f",
+ ...shadowStyle,
+ });
+ },
+ );
+
+ test("color-mix() of currentcolor on color mixes the inherited colour", () => {
+ registerCSS(`
+ .parent { color: #ff0000; }
+ .child { color: color-mix(in srgb, currentcolor, #0000ff); }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "rgba(127.5, 0, 127.5, 1)",
+ });
+ });
+});
diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx
index 0e7a2840..7b9e99c3 100644
--- a/src/__tests__/native/filter-list-runtime.test.tsx
+++ b/src/__tests__/native/filter-list-runtime.test.tsx
@@ -58,6 +58,28 @@ describe.each([undefined, false] as const)(
screen.rerender();
expect(filter()).toEqual([firstShadow, secondShadow]);
});
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter for each
+ test.each([
+ ["filter: var(--effect)", "none", [{ blur: 3 }]],
+ [
+ "filter: brightness(0.5) var(--effect)",
+ "none",
+ [{ brightness: 0.5 }, { blur: 3 }],
+ ],
+ ])(
+ "%s with --effect: %s removes the filter",
+ (declaration, keyword, before) => {
+ registerCSS(
+ `.subject { --effect: blur(3px); ${declaration}; } .keyword { --effect: ${keyword}; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toEqual(before);
+ screen.rerender();
+ expect(filter()).toBeUndefined();
+ },
+ );
},
);
diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx
index f1b3defc..cc57e1af 100644
--- a/src/__tests__/native/keywords.test.tsx
+++ b/src/__tests__/native/keywords.test.tsx
@@ -1,17 +1,169 @@
-import { View } from "react-native";
-
-import { renderHook } from "@testing-library/react-native";
+import { render, screen } from "@testing-library/react-native";
+import { View } from "react-native-css/components/View";
import { registerCSS } from "react-native-css/jest";
-import { useNativeCss } from "react-native-css/native";
-test("unset", () => {
- registerCSS(`.my-class { background-color: unset; }`);
+const resettingKeywords = ["initial", "unset", "revert", "revert-layer"];
+
+const clearedProperties: readonly (readonly [
+ property: string,
+ earlier: string,
+ cleared: Record,
+])[] = [
+ ["background-color", "red", { backgroundColor: undefined }],
+ ["opacity", "0.5", { opacity: undefined }],
+ ["box-shadow", "1px 1px red", { boxShadow: undefined }],
+ ["filter", "blur(2px)", { filter: undefined }],
+ ["transform", "translateX(10px)", {}],
+ ["line-height", "30px", {}],
+ [
+ "text-shadow",
+ "1px 1px red",
+ {
+ textShadowColor: undefined,
+ textShadowOffset: undefined,
+ textShadowRadius: undefined,
+ },
+ ],
+ [
+ "border",
+ "2px solid red",
+ { borderWidth: undefined, borderStyle: undefined, borderColor: undefined },
+ ],
+];
+
+function renderStyle(css: string): unknown {
+ registerCSS(css);
+
+ render();
- const { result } = renderHook(() => {
- return useNativeCss(View, { className: "my-class" });
- });
+ return screen.getByTestId("child").props.style;
+}
- expect(result.current.props).toStrictEqual({
- style: { backgroundColor: undefined },
- });
+// Chromium 153, Firefox 155 and WebKit 26.6 reset each of these to its initial value, which React Native's default matches
+test.each(
+ clearedProperties.flatMap(([property, earlier, cleared]) =>
+ resettingKeywords.map(
+ (keyword) => [property, keyword, earlier, cleared] as const,
+ ),
+ ),
+)(
+ "%s: %s clears an earlier rule's value",
+ (property, keyword, earlier, cleared) => {
+ expect(
+ renderStyle(
+ `.earlier { ${property}: ${earlier}; } .child { ${property}: ${keyword}; }`,
+ ),
+ ).toStrictEqual(cleared);
+ },
+);
+
+test.each(resettingKeywords)(
+ "border-block-width: %s resets to React Native's default width",
+ (keyword) => {
+ expect(
+ renderStyle(
+ `.earlier { border-block-width: 3px; } .child { border-block-width: ${keyword}; }`,
+ ),
+ ).toStrictEqual({
+ borderTopWidth: undefined,
+ borderBottomWidth: undefined,
+ });
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply a keyword fallback of an undefined env() as the whole value
+test("padding-top: env(safe-area-inset-top, initial) clears an earlier rule's value when no inset is defined", () => {
+ expect(
+ renderStyle(
+ `.earlier { padding-top: 10px; } .child { padding-top: env(safe-area-inset-top, initial); }`,
+ ),
+ ).toStrictEqual({ paddingTop: undefined });
});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2)
+test.each(["inherit", "unset", "revert", "revert-layer"])(
+ "--brand: %s reads the ancestor's --brand",
+ (keyword) => {
+ registerCSS(`
+ .parent { --brand: green; }
+ .child { --brand: ${keyword}; color: var(--brand); }
+ .other { --brand: ${keyword}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
+ });
+ },
+);
+
+const earlierShadow = {
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#f00",
+ },
+ ],
+};
+
+// Chromium 153, Firefox 155 and WebKit 26.6 ignore each invalid declaration and keep the earlier value
+test.each([
+ ["filter", "foo(1)", "blur(2px)", { filter: [{ blur: 2 }] }],
+ ["box-shadow", "foo(1)", "1px 1px red", earlierShadow],
+ ["box-shadow", "1px 1px inherit", "1px 1px red", earlierShadow],
+ [
+ "transform",
+ "foo(1)",
+ "translateX(10px)",
+ { transform: [{ translateX: 10 }] },
+ ],
+] as const)(
+ "%s: %s leaves an earlier rule's value",
+ (property, invalid, earlier, kept) => {
+ expect(
+ renderStyle(
+ `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`,
+ ),
+ ).toStrictEqual(kept);
+ },
+);
+
+test.each([
+ [
+ "object-position",
+ "left inherit",
+ "right top",
+ { contentPosition: "right top" },
+ ],
+ [
+ "font-variant",
+ "all-small-caps inherit",
+ "small-caps",
+ { style: { fontVariant: "small-caps" } },
+ ],
+ [
+ "corner-shape",
+ "round inherit",
+ "squircle",
+ { style: { borderCurve: "continuous" } },
+ ],
+] as const)(
+ "%s: %s leaves an earlier rule's value",
+ (property, invalid, earlier, kept) => {
+ registerCSS(
+ `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props).toMatchObject(kept);
+ },
+);
diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx
index 7a59b816..fed88597 100644
--- a/src/__tests__/native/transform.test.tsx
+++ b/src/__tests__/native/transform.test.tsx
@@ -154,3 +154,67 @@ describe("transform", () => {
});
});
});
+
+describe("a transform's target survives a later nested declaration", () => {
+ test("translate before a box-shadow lands in transform", () => {
+ registerCSS(
+ `.my-class { translate: 10px 20px; box-shadow: 1px 1px blue; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 10 }, { translateY: 20 }],
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("declaration order does not matter", () => {
+ registerCSS(
+ `.my-class { box-shadow: 1px 1px blue; translate: 10px 20px; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 10 }, { translateY: 20 }],
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 leave no transform for each
+test.each([
+ ["transform: var(--t)", "none"],
+ ["transform: translateX(10px) var(--t)", "none"],
+])("%s with --t: %s leaves no transform", (declaration, keyword) => {
+ registerCSS(
+ `.my-class { --t: rotate(1deg); ${declaration}; } .keyword { --t: ${keyword}; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({});
+});
diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx
index 00d184b7..3ff4e94a 100644
--- a/src/__tests__/vendor/tailwind/typography.test.tsx
+++ b/src/__tests__/vendor/tailwind/typography.test.tsx
@@ -322,9 +322,15 @@ describe("Typography - Text Color", () => {
});
});
test("text-inherit", async () => {
+ // currentColor on `color` is `inherit` (css-color-3 §4.4), so text-inherit renders as text-current does
expect(await renderCurrentTest()).toStrictEqual({
- props: {},
- warnings: { values: { color: "inherit" } },
+ props: {
+ style: {
+ color: {
+ semantic: ["label", "labelColor"],
+ },
+ },
+ },
});
});
});
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 9ca2aab8..f6fff6ff 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -34,9 +34,14 @@ import type {
TokenOrValue,
Translate,
UnresolvedColor,
+ Variable,
} from "lightningcss";
-import { isStyleFunction } from "../utilities";
+import {
+ INHERITED_COLOR_VARIABLE,
+ isInheritedColorLookup,
+ isStyleFunction,
+} from "../utilities";
import { isMappingProperty } from "./atRules";
import type {
StyleDescriptor,
@@ -983,17 +988,24 @@ export function parseUnparsedDeclaration(
*/
builder.descriptorProperty = property;
- if (unparsedRuntimeParsing.has(property)) {
- const args = parseUnparsed(declaration.value.value, builder, property);
+ const value = parseDeclarationValue(
+ declaration.value.value,
+ builder,
+ property,
+ );
+
+ // A value the compiler cannot parse leaves the declaration invalid, so an earlier rule's value stands
+ if (value === undefined) {
+ return;
+ }
+ if (unparsedRuntimeParsing.has(property)) {
if (property === "animation") {
- builder.addDescriptor("animation", [{}, property, args]);
+ builder.addDescriptor("animation", [{}, property, value]);
} else {
- builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]);
+ builder.addDescriptor(property, [{}, toRNProperty(property), value, 1]);
}
} else {
- const value = parseUnparsed(declaration.value.value, builder, property);
-
builder.addDescriptor(property, value);
if (property === "font-size") {
@@ -1001,15 +1013,305 @@ export function parseUnparsedDeclaration(
}
if (property === "color") {
- if (
- !isStyleFunction(value) ||
- value[1] !== "var" ||
- value[2] !== "-css-color"
- ) {
- builder.addDescriptor("--__rn-css-color", value);
+ publishInheritedColor(value, builder);
+ }
+ }
+}
+
+// A fresh tuple per call, because a descriptor is owned by the rule it lands in
+function inheritedColorLookup() {
+ return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction;
+}
+
+const CSS_WIDE_KEYWORDS = [
+ "inherit",
+ "initial",
+ "unset",
+ "revert",
+ "revert-layer",
+] as const;
+
+type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number];
+
+function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword {
+ return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword);
+}
+
+interface SoleCSSWideKeyword {
+ keyword: CSSWideKeyword;
+ written: string;
+}
+
+// A CSS-wide keyword is a value only on its own (css-values-4 §2.1)
+function soleCSSWideKeyword(
+ tokens: TokenOrValue[] | null | undefined,
+): SoleCSSWideKeyword | undefined {
+ const [token, ...rest] = tokens ?? [];
+
+ if (
+ token === undefined ||
+ rest.length > 0 ||
+ token.type !== "token" ||
+ token.value.type !== "ident"
+ ) {
+ return;
+ }
+
+ const written = token.value.value;
+ const keyword = written.toLowerCase();
+
+ return isCSSWideKeyword(keyword) ? { keyword, written } : undefined;
+}
+
+function parseCSSWideKeyword(
+ { keyword, written }: SoleCSSWideKeyword,
+ builder: StylesheetBuilder,
+ property: string,
+): StyleDescriptor {
+ const isCustomProperty = property.startsWith("--");
+ // Only `color` and custom properties reach descendants here, so only they inherit where `unset` would (css-cascade-5 §7.3.3)
+ const inherits = isCustomProperty || property === "color";
+ // React Native has no user-agent or user origin, so `revert` and `revert-layer` roll back to `unset` (css-cascade-5 §7.3.4, §7.3.5)
+ const resolved =
+ keyword === "inherit" || keyword === "initial"
+ ? keyword
+ : inherits
+ ? "inherit"
+ : "initial";
+
+ if (resolved === "inherit" && property === "color") {
+ return inheritedColorLookup();
+ } else if (resolved === "inherit" && isCustomProperty) {
+ // An undeclared custom property already reads its ancestor's value
+ return;
+ } else if (resolved === "initial" && !isCustomProperty) {
+ // The literal the runtime clears with, which leaves React Native's default to stand in for the initial value
+ return "unset";
+ }
+
+ builder.addWarning("value", written);
+ return;
+}
+
+// The value a declaration's CSS-wide keyword gives it, or undefined when it holds none
+function parseCSSWideKeywordValue(
+ tokens: TokenOrValue[] | null | undefined,
+ builder: StylesheetBuilder,
+ property: string,
+): { readonly value: StyleDescriptor } | undefined {
+ const sole = soleCSSWideKeyword(tokens);
+
+ if (sole !== undefined) {
+ return { value: parseCSSWideKeyword(sole, builder, property) };
+ }
+
+ // A CSS-wide keyword inside a property's longer value makes the declaration invalid (css-values-4 §2.1); a custom property's value is any tokens
+ const nested = property.startsWith("--")
+ ? undefined
+ : nestedCSSWideKeyword(tokens ?? []);
+
+ if (nested !== undefined) {
+ builder.addWarning("value", nested);
+ return { value: undefined };
+ }
+
+ return;
+}
+
+function parseDeclarationValue(
+ tokens: TokenOrValue[] | null | undefined,
+ builder: StylesheetBuilder,
+ property: string,
+ allowAuto?: boolean,
+): StyleDescriptor {
+ const keywordValue = parseCSSWideKeywordValue(tokens, builder, property);
+
+ if (keywordValue !== undefined) {
+ return keywordValue.value;
+ }
+
+ const [token, ...rest] = tokens ?? [];
+
+ if (token === undefined || rest.length > 0) {
+ return parseUnparsed(tokens, builder, property, allowAuto);
+ }
+
+ switch (token.type) {
+ case "var":
+ return listSoleFunction(
+ parseVariable(token.value, builder, property, "whole-value", allowAuto),
+ property,
+ );
+ case "env":
+ return listSoleFunction(
+ parseEnv(token.value, builder, property, "whole-value"),
+ property,
+ );
+ default:
+ return parseUnparsed(tokens, builder, property, allowAuto);
+ }
+}
+
+// A filter or transform value is a list of functions, so one function alone is a list of one
+function listSoleFunction(
+ value: StyleDescriptor,
+ property: string,
+): StyleDescriptor {
+ return (property === "filter" || property === "transform") &&
+ isStyleFunction(value)
+ ? [value]
+ : value;
+}
+
+// Substituted, a fallback replaces its var() or env(), so it is the declaration's whole value only when the function is (css-variables-1 §3)
+type SubstitutionPosition = "whole-value" | "inside-value";
+
+function parseSubstitutionFallback(
+ tokens: TokenOrValue[] | null | undefined,
+ builder: StylesheetBuilder,
+ property: string,
+ position: SubstitutionPosition,
+ allowAuto?: boolean,
+): StyleDescriptor {
+ switch (position) {
+ case "whole-value":
+ return parseDeclarationValue(tokens, builder, property, allowAuto);
+ case "inside-value": {
+ // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1)
+ const keyword = nestedCSSWideKeyword(tokens ?? []);
+
+ if (keyword !== undefined) {
+ builder.addWarning("value", keyword);
+ return;
}
+
+ return parseUnparsed(tokens, builder, property, allowAuto);
+ }
+ default: {
+ position satisfies never;
+ return;
+ }
+ }
+}
+
+function parseVariable(
+ variable: Variable,
+ builder: StylesheetBuilder,
+ property: string,
+ position: SubstitutionPosition,
+ allowAuto?: boolean,
+): StyleFunction {
+ const name = variable.name.ident.slice(2);
+ const fallback = parseSubstitutionFallback(
+ variable.fallback,
+ builder,
+ property,
+ position,
+ allowAuto,
+ );
+
+ return [{}, "var", fallback === undefined ? name : [name, fallback], 1];
+}
+
+// A var() or env() defers its value to computed-value time, so a keyword behind one is not looked for
+function nestedCSSWideKeyword(
+ tokens: readonly TokenOrValue[],
+): string | undefined {
+ for (const tokenOrValue of tokens) {
+ const keyword = cssWideKeywordIn(tokenOrValue);
+
+ if (keyword !== undefined) {
+ return keyword;
}
}
+
+ return;
+}
+
+function cssWideKeywordIn(tokenOrValue: TokenOrValue): string | undefined {
+ switch (tokenOrValue.type) {
+ case "token":
+ return tokenOrValue.value.type === "ident" &&
+ isCSSWideKeyword(tokenOrValue.value.value.toLowerCase())
+ ? tokenOrValue.value.value
+ : undefined;
+ case "function":
+ return nestedCSSWideKeyword(tokenOrValue.value.arguments);
+ case "unresolved-color":
+ return cssWideKeywordInColor(tokenOrValue.value);
+ case "var":
+ case "env":
+ case "color":
+ case "url":
+ case "length":
+ case "angle":
+ case "time":
+ case "resolution":
+ case "dashed-ident":
+ case "animation-name":
+ return;
+ default: {
+ tokenOrValue satisfies never;
+ return;
+ }
+ }
+}
+
+function cssWideKeywordInColor(color: UnresolvedColor): string | undefined {
+ switch (color.type) {
+ case "rgb":
+ case "hsl":
+ return nestedCSSWideKeyword(color.alpha);
+ case "light-dark":
+ return (
+ nestedCSSWideKeyword(color.light) ?? nestedCSSWideKeyword(color.dark)
+ );
+ default: {
+ color satisfies never;
+ return;
+ }
+ }
+}
+
+function addCSSWideKeyword(
+ tokens: TokenOrValue[],
+ builder: StylesheetBuilder,
+ property: string,
+ nativeProperty = property,
+): boolean {
+ const keywordValue = parseCSSWideKeywordValue(tokens, builder, property);
+
+ if (keywordValue === undefined) {
+ return false;
+ }
+
+ builder.addDescriptor(nativeProperty, keywordValue.value);
+
+ return true;
+}
+
+// A descendant resolves a published colour lazily, so one that reads the inherited colour publishes the bare lookup, which the runtime answers with what the element inherited
+function publishInheritedColor(
+ value: StyleDescriptor,
+ builder: StylesheetBuilder,
+) {
+ builder.addDescriptor(
+ `--${INHERITED_COLOR_VARIABLE}`,
+ readsInheritedColor(value) ? inheritedColorLookup() : value,
+ );
+}
+
+// A fallback, a function argument or a light-dark() arm can hold the read, so the whole tree is walked
+function readsInheritedColor(value: StyleDescriptor): boolean {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+
+ if (isStyleFunction(value)) {
+ return isInheritedColorLookup(value) || readsInheritedColor(value[2]);
+ }
+
+ return value.some((entry) => readsInheritedColor(entry));
}
export function parseCustomDeclaration(
@@ -1018,13 +1320,15 @@ export function parseCustomDeclaration(
) {
const property = declaration.value.name;
+ builder.setWarningProperty(property);
+
if (isMappingProperty(property)) {
// Consumed before declarations so property order cannot affect mapping.
return;
} else if (property === "-webkit-line-clamp") {
builder.addDescriptor(
property,
- parseUnparsed(declaration.value.value, builder, property),
+ parseDeclarationValue(declaration.value.value, builder, property),
);
} else if (property === "-rn-ripple-style") {
if (
@@ -1048,11 +1352,15 @@ export function parseCustomDeclaration(
// https://github.com/parcel-bundler/lightningcss/issues/1048
builder.addDescriptor(
property,
- parseUnparsed(declaration.value.value, builder, property),
+ parseDeclarationValue(declaration.value.value, builder, property),
);
} else if (property === "corner-shape") {
parseCornerShape(declaration.value, builder);
} else if (property === "font-variant") {
+ if (addCSSWideKeyword(declaration.value.value, builder, property)) {
+ return;
+ }
+
const parsedFontVariant = parseFontVariantCaps(
parseUnparsed(
declaration.value.value,
@@ -1071,7 +1379,7 @@ export function parseCustomDeclaration(
) {
builder.addDescriptor(
property,
- parseUnparsed(declaration.value.value, builder, property),
+ parseDeclarationValue(declaration.value.value, builder, property),
);
} else {
builder.addWarning("property", declaration.value.name);
@@ -1160,13 +1468,7 @@ export function unparsedFunction(
* This export function best efforts parsing it into a export function that we can evaluate at runtime
*/
export function parseUnparsed(
- tokenOrValue:
- | TokenOrValue
- | TokenOrValue[]
- | string
- | number
- | undefined
- | null,
+ tokenOrValue: TokenOrValue | TokenOrValue[] | undefined | null,
builder: StylesheetBuilder,
property: string,
allowAuto = allowAutoProperties.has(property),
@@ -1175,22 +1477,6 @@ export function parseUnparsed(
return;
}
- if (typeof tokenOrValue === "string") {
- if (tokenOrValue === "true") {
- return true;
- } else if (tokenOrValue === "false") {
- return false;
- } else if (tokenOrValue === "currentcolor") {
- return [{}, "var", "__rn-css-color"] as const;
- } else {
- return tokenOrValue;
- }
- }
-
- if (typeof tokenOrValue === "number") {
- return round(tokenOrValue);
- }
-
if (Array.isArray(tokenOrValue)) {
const args = reduceParseUnparsed(
tokenOrValue,
@@ -1201,14 +1487,9 @@ export function parseUnparsed(
if (!args) return;
if (Array.isArray(args) && args.length === 1) {
return args[0];
- } else if (
- (property === "filter" || property === "transform") &&
- isStyleFunction(args)
- ) {
- return [args];
- } else {
- return args;
}
+
+ return listSoleFunction(args, property);
}
switch (tokenOrValue.type) {
@@ -1220,20 +1501,14 @@ export function parseUnparsed(
allowAuto,
);
}
- case "var": {
- let args: StyleDescriptor = tokenOrValue.value.name.ident.slice(2);
- const fallback = parseUnparsed(
- tokenOrValue.value.fallback,
+ case "var":
+ return parseVariable(
+ tokenOrValue.value,
builder,
property,
+ "inside-value",
allowAuto,
);
- if (fallback !== undefined) {
- args = [args, fallback];
- }
-
- return [{}, "var", args, 1];
- }
case "function": {
switch (tokenOrValue.value.name) {
case "blur":
@@ -1317,11 +1592,17 @@ export function parseUnparsed(
return;
}
- if (value === "inherit" || value === "initial") {
+ // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded
+ const keyword = value.toLowerCase();
+
+ if (keyword === "currentcolor") {
+ return inheritedColorLookup();
+ }
+
+ // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1)
+ if (isCSSWideKeyword(keyword)) {
builder.addWarning("value", value);
return;
- } else if (value === "currentcolor") {
- return [{}, "var", "__rn-css-color"] as const;
}
if (value === "true") {
@@ -1384,7 +1665,7 @@ export function parseUnparsed(
case "color":
return parseColor(tokenOrValue.value, builder);
case "env":
- return parseEnv(tokenOrValue.value, builder);
+ return parseEnv(tokenOrValue.value, builder, property, "inside-value");
case "time":
return parseTime(tokenOrValue.value);
case "url":
@@ -1643,17 +1924,11 @@ export function parseFontColorDeclaration(
declaration: Extract,
builder: StylesheetBuilder,
) {
- parseColorDeclaration(declaration, builder);
+ // Parsed once: light-dark() adds its dark rule as a side effect, so a second parse adds it twice
+ const value = parseColor(declaration.value, builder);
- if (
- typeof declaration.value !== "object" ||
- declaration.value.type !== "currentcolor"
- ) {
- builder.addDescriptor(
- "--__rn-css-color",
- parseColor(declaration.value, builder),
- );
- }
+ builder.addDescriptor(declaration.property, value);
+ publishInheritedColor(value, builder);
}
export function parseColorDeclaration(
@@ -1692,7 +1967,7 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) {
switch (cssColor.type) {
case "currentcolor":
- return [{}, "var", "__rn-css-color"] as const;
+ return inheritedColorLookup();
case "light-dark": {
const extraRule: StyleRule = {
s: [],
@@ -2673,6 +2948,8 @@ const allowAutoProperties = new Set(["pointer-events"]);
export function parseEnv(
value: EnvironmentVariable,
builder: StylesheetBuilder,
+ property: string,
+ position: SubstitutionPosition,
): StyleFunction | undefined {
switch (value.name.type) {
case "ua":
@@ -2681,15 +2958,20 @@ export function parseEnv(
case "safe-area-inset-right":
case "safe-area-inset-bottom":
case "safe-area-inset-left": {
- const fallback = parseUnparsed(
+ const name = `react-native-css-${value.name.value}`;
+ const fallback = parseSubstitutionFallback(
value.fallback,
builder,
- value.name.value,
+ property,
+ position,
);
- return fallback
- ? [{}, "var", [`react-native-css-${value.name.value}`, fallback], 1]
- : [{}, "var", [`react-native-css-${value.name.value}`], 1];
+ return [
+ {},
+ "var",
+ fallback === undefined ? [name] : [name, fallback],
+ 1,
+ ];
}
case "viewport-segment-width":
case "viewport-segment-height":
@@ -2824,7 +3106,7 @@ export function parseCalcArguments(
for (const arg of args) {
switch (arg.type) {
case "env": {
- parsed.push(parseEnv(arg.value, builder));
+ parsed.push(parseEnv(arg.value, builder, property, "inside-value"));
break;
}
case "var":
@@ -3277,7 +3559,7 @@ function parseObjectFit(
});
builder.addDescriptor(
"object-fit",
- parseUnparsed(declaration.value, builder, "object-fit"),
+ parseDeclarationValue(declaration.value, builder, "object-fit"),
);
}
@@ -3288,6 +3570,11 @@ function parseObjectPosition(
builder.addMapping({
"object-position": ["contentPosition"],
});
+
+ if (addCSSWideKeyword(declaration.value, builder, "object-position")) {
+ return;
+ }
+
builder.addDescriptor("object-position", [
{},
"join",
@@ -3299,6 +3586,12 @@ function parseCornerShape(
declaration: CustomProperty,
builder: StylesheetBuilder,
) {
+ if (
+ addCSSWideKeyword(declaration.value, builder, "corner-shape", "borderCurve")
+ ) {
+ return;
+ }
+
const shape = parseUnparsed(declaration.value, builder, "corner-shape");
if (shape === "round") {
diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts
index d379da8a..e754be44 100644
--- a/src/compiler/pseudo-elements.ts
+++ b/src/compiler/pseudo-elements.ts
@@ -1,4 +1,4 @@
-import { isStyleFunction } from "../utilities";
+import { INHERITED_COLOR_VARIABLE, isStyleFunction } from "../utilities";
import type { StyleDeclaration, StyleRule } from "./compiler.types";
export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined {
@@ -10,7 +10,7 @@ export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined {
return modifyStyleDeclaration(declaration, "color", "selectionColor");
});
- return rule;
+ return withoutInheritedColor(rule);
}
export function modifyRuleForPlaceholder(
@@ -24,6 +24,21 @@ export function modifyRuleForPlaceholder(
return modifyStyleDeclaration(declaration, "color", "placeholderTextColor");
});
+ return withoutInheritedColor(rule);
+}
+
+// A pseudo-element's colour is its own, so the element and its subtree keep theirs
+function withoutInheritedColor(rule: StyleRule): StyleRule {
+ const variables = rule.v?.filter(
+ ([name]) => name !== INHERITED_COLOR_VARIABLE,
+ );
+
+ if (variables?.length) {
+ rule.v = variables;
+ } else {
+ delete rule.v;
+ }
+
return rule;
}
diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts
index e0fe8d1a..c621f0c5 100644
--- a/src/native/react/rules.ts
+++ b/src/native/react/rules.ts
@@ -1,6 +1,14 @@
/* eslint-disable */
-import type { InlineVariable, StyleRule } from "react-native-css/compiler";
+import type {
+ InlineVariable,
+ StyleDescriptor,
+ StyleRule,
+} from "react-native-css/compiler";
import { StyleCollection } from "react-native-css/native-internal";
+import {
+ assignPublishedColor,
+ assignRuleVariables,
+} from "react-native-css/utilities";
import { testRule } from "../conditions";
import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query";
@@ -41,6 +49,7 @@ export function updateRules(
let usesVariables = false;
let variables: VariableContextValue | undefined;
+ let publishedColor: StyleDescriptor | undefined;
let containers: ContainerContextValue | undefined;
const inlineVariables = new Set();
@@ -131,13 +140,12 @@ export function updateRules(
}
if (rule.v) {
- if (variables === inheritedVariables) {
+ if (variables === undefined || variables === inheritedVariables) {
variables = { ...inheritedVariables };
}
- for (const v of rule.v) {
- variables![v[0]] = v[1];
- }
+ publishedColor =
+ assignRuleVariables(variables, rule.v) ?? publishedColor;
}
if (rule.c) {
@@ -206,6 +214,10 @@ export function updateRules(
}
}
+ if (variables !== undefined && variables !== inheritedVariables) {
+ assignPublishedColor(variables, publishedColor);
+ }
+
pressable = activeFamily.has(state.ruleEffectGetter);
if (!rules.size && !state.stylesObs && !inlineVariables.size) {
diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..c082a08e 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -2,9 +2,14 @@
import type {
InlineVariable,
StyleDeclaration,
+ StyleDescriptor,
StyleRule,
} from "react-native-css/compiler";
-import { Specificity as S } from "react-native-css/utilities";
+import {
+ assignPublishedColor,
+ assignRuleVariables,
+ Specificity as S,
+} from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
import { applyValue, getDeepPath } from "../objects";
@@ -32,6 +37,7 @@ export function calculateProps(
const delayedStyles: (() => void)[] = [];
const transformStyles: (() => void)[] = [];
+ let publishedColor: StyleDescriptor | undefined;
for (const rule of rules) {
if (VAR_SYMBOL in rule) {
@@ -44,9 +50,8 @@ export function calculateProps(
}
if (rule.v) {
- for (const variable of rule.v) {
- inlineVariables[variable[0]] = variable[1];
- }
+ publishedColor =
+ assignRuleVariables(inlineVariables, rule.v) ?? publishedColor;
}
if (rule.d) {
@@ -79,6 +84,8 @@ export function calculateProps(
}
}
+ assignPublishedColor(inlineVariables, publishedColor);
+
for (const delayedStyle of delayedStyles) {
delayedStyle();
}
@@ -105,19 +112,21 @@ export function applyDeclarations(
target: Record = {},
topLevelTarget = target,
) {
- const originalTarget = target;
-
for (const declaration of declarations) {
- target = originalTarget;
+ // The delayed and transform closures below run after the walk, so each must capture its own declaration's target
+ let declarationTarget = target;
if (!Array.isArray(declaration)) {
// Static styles
- Object.assign(target, declaration);
+ Object.assign(declarationTarget, declaration);
} else {
// Dynamic styles
let value: any = declaration[0];
let propPath = declaration[1];
let prop: string | number;
+ // Only the element's own `color`: a shadow's colour is a nested `color` key that keeps the cascading read
+ const computingColor =
+ (Array.isArray(propPath) ? propPath.join(".") : propPath) === "color";
if (Array.isArray(propPath)) {
const [first, ...rest] = propPath;
@@ -131,7 +140,7 @@ export function applyDeclarations(
if (final) {
if (first !== "&") {
topLevelTarget[first] ??= {};
- target = topLevelTarget[first];
+ declarationTarget = topLevelTarget[first];
}
let previousProp: string | number = first;
@@ -143,19 +152,19 @@ export function applyDeclarations(
if (!Array.isArray(previousTarget[previousProp])) {
previousTarget[previousProp] = [];
- target = previousTarget[previousProp];
+ declarationTarget = previousTarget[previousProp];
}
}
- previousTarget = target;
+ previousTarget = declarationTarget;
previousProp = prop;
- target[prop] ??= {};
- target = target[prop];
+ declarationTarget[prop] ??= {};
+ declarationTarget = declarationTarget[prop];
}
prop = final;
} else {
- target = topLevelTarget;
+ declarationTarget = topLevelTarget;
prop = first;
}
} else {
@@ -185,20 +194,22 @@ export function applyDeclarations(
inheritedVariables,
renderGuards: guards,
calculateProps,
+ computingColor,
});
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
});
} else {
delayedStyles.push(() => {
- if (getDeepPath(target, prop) === value) {
- delete target[prop];
+ if (getDeepPath(declarationTarget, prop) === value) {
+ delete declarationTarget[prop];
value = resolveValue(originalValue, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
+ computingColor,
});
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
}
});
}
@@ -208,10 +219,11 @@ export function applyDeclarations(
inheritedVariables,
renderGuards: guards,
calculateProps,
+ computingColor,
});
}
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
}
}
}
diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts
index 4a88a98a..92e6bdd0 100644
--- a/src/native/styles/functions/filters.ts
+++ b/src/native/styles/functions/filters.ts
@@ -1,5 +1,6 @@
import { isStyleDescriptorArray } from "react-native-css/utilities";
+import { resetsToNone } from "../resets-to-none";
import type { StyleFunctionResolver } from "../resolve";
import { shorthandHandler } from "../shorthands/_handler";
@@ -103,11 +104,10 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => {
const value: unknown = resolveValue(descriptor[2]);
if (Array.isArray(value)) {
const filters: unknown[] = value.flat(Infinity);
- return filters.filter(
- (entry) => entry !== undefined && entry !== "initial",
- );
+ if (filters.some(resetsToNone)) return null;
+ return filters.filter((entry) => entry !== undefined);
}
if (value === undefined || value === "initial") return;
- if (value === "none") return null;
+ if (resetsToNone(value)) return null;
return typeof value === "string" ? value : [value];
};
diff --git a/src/native/styles/resets-to-none.ts b/src/native/styles/resets-to-none.ts
new file mode 100644
index 00000000..6f79991b
--- /dev/null
+++ b/src/native/styles/resets-to-none.ts
@@ -0,0 +1,4 @@
+// A keyword a var() leaves in a list-valued property is `none` alone or invalid at computed-value time, and either way the property is `none` (css-variables-1 §3.1)
+export function resetsToNone(value: unknown): boolean {
+ return value === null || value === "none" || value === "initial";
+}
diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts
index 0cb40385..27c00309 100644
--- a/src/native/styles/resolve.ts
+++ b/src/native/styles/resolve.ts
@@ -5,7 +5,10 @@ import type {
StyleDescriptor,
StyleFunction,
} from "react-native-css/compiler";
-import { isStyleDescriptorArray } from "react-native-css/utilities";
+import {
+ isInheritedColorLookup,
+ isStyleDescriptorArray,
+} from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
import { type Getter, type VariableContextValue } from "../reactivity";
@@ -54,6 +57,8 @@ export type ResolveValueOptions = {
renderGuards?: RenderGuard[];
variableHistory?: Set;
variableCycles?: Set;
+ /** Resolving the element's own `color`, where css-color-3 §4.4 makes `currentcolor` the inherited colour */
+ computingColor?: boolean;
/** Pass down to perform recursive calculations and avoid circular dependencies */
calculateProps?: typeof calculateProps;
};
@@ -109,6 +114,21 @@ export function resolveValue(
};
if (name === "var") {
+ if (options.computingColor && isInheritedColorLookup(value)) {
+ const inheritedScope: ResolveValueOptions = {
+ ...options,
+ inlineVariables: undefined,
+ computingColor: false,
+ };
+
+ return varResolver(
+ (inner) => resolveValue(inner, get, inheritedScope),
+ value,
+ get,
+ inheritedScope,
+ );
+ }
+
return varResolver(simpleResolve, value, get, options);
} else if (name in functionResolvers) {
const fn = functionResolvers[name as keyof typeof functionResolvers];
diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts
index b8147d1a..e20f4a53 100644
--- a/src/native/styles/shorthands/_handler.ts
+++ b/src/native/styles/shorthands/_handler.ts
@@ -38,6 +38,10 @@ export function shorthandHandler(
? resolve(value[2])
: value;
+ if (args === null && returnType === "shorthandObject") {
+ return clearedLonghands(mappings, defaults);
+ }
+
if (!Array.isArray(args)) {
return;
}
@@ -133,3 +137,20 @@ export function shorthandHandler(
}
};
}
+
+// The runtime resolves `unset` to null, and a shorthand reset that way resets every longhand it sets (css-cascade-5 §7.3.3)
+function clearedLonghands(
+ mappings: ShorthandRequiredValue[][],
+ defaults: ShorthandDefaultValue[],
+) {
+ const target: Record = { [ShortHandSymbol]: true };
+ const longhands = [...mappings.flat(), ...defaults].flatMap(([prop]) =>
+ typeof prop === "string" ? [prop] : prop.slice(0, 1),
+ );
+
+ for (const longhand of longhands) {
+ target[longhand] = undefined;
+ }
+
+ return target;
+}
diff --git a/src/native/styles/shorthands/logical-border-width.ts b/src/native/styles/shorthands/logical-border-width.ts
index 147a3517..3913c12f 100644
--- a/src/native/styles/shorthands/logical-border-width.ts
+++ b/src/native/styles/shorthands/logical-border-width.ts
@@ -4,6 +4,11 @@ import type { StyleFunctionResolver } from "../resolve";
function logicalWidth(start: string, end: string): StyleFunctionResolver {
return (resolve, descriptor) => {
const resolved = resolve(descriptor[2]);
+
+ if (resolved === null) {
+ return { [ShortHandSymbol]: true, [start]: undefined, [end]: undefined };
+ }
+
const values: unknown[] = Array.isArray(resolved) ? resolved : [resolved];
if (
values.length < 1 ||
diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts
index ef603dd8..1f2852a7 100644
--- a/src/native/styles/shorthands/transform.ts
+++ b/src/native/styles/shorthands/transform.ts
@@ -1,3 +1,4 @@
+import { resetsToNone } from "../resets-to-none";
import type { StyleFunctionResolver } from "../resolve";
/**
@@ -11,9 +12,9 @@ export const transform: StyleFunctionResolver = (
const transforms = resolveValue(transformDescriptor[2]);
if (Array.isArray(transforms)) {
- return transforms.filter(
- (transform) => transform !== undefined && transform !== "initial",
- ) as unknown;
+ return transforms.some(resetsToNone)
+ ? undefined
+ : (transforms.filter((transform) => transform !== undefined) as unknown);
} else if (transforms) {
// If it's a single transform, wrap it in an array
return [transforms];
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
index 0c95da63..dd762410 100644
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -1,3 +1,4 @@
export * from "./specificity";
export * from "./style-descriptor";
export * from "./dot-notation.types";
+export * from "./inherited-color";
diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts
new file mode 100644
index 00000000..d025a92b
--- /dev/null
+++ b/src/utilities/inherited-color.ts
@@ -0,0 +1,52 @@
+import type {
+ StyleDescriptor,
+ StyleFunction,
+ StyleRule,
+} from "react-native-css/compiler";
+
+import { isStyleFunction } from "./style-descriptor";
+
+/** The custom property every `color` rule publishes to its subtree and `currentcolor` reads back */
+export const INHERITED_COLOR_VARIABLE = "__rn-css-color";
+
+export function isInheritedColorLookup(fn: StyleFunction): boolean {
+ if (fn[1] !== "var") {
+ return false;
+ }
+
+ const args = fn[2];
+ const name = Array.isArray(args) ? args[0] : args;
+
+ return name === INHERITED_COLOR_VARIABLE;
+}
+
+/** Assigns a rule's custom properties, and returns the inherited colour it publishes for `assignPublishedColor` */
+export function assignRuleVariables(
+ variables: Record,
+ entries: NonNullable,
+): StyleDescriptor | undefined {
+ let color: StyleDescriptor | undefined;
+
+ for (const [name, value] of entries) {
+ if (name === INHERITED_COLOR_VARIABLE) {
+ color = value;
+ } else {
+ variables[name] = value;
+ }
+ }
+
+ return color;
+}
+
+// The last rule's colour is the element's, and one that reads the inherited colour leaves the inherited one in place, as CSS inherits a computed value
+export function assignPublishedColor(
+ variables: Record,
+ color: StyleDescriptor | undefined,
+): void {
+ if (
+ color !== undefined &&
+ !(isStyleFunction(color) && isInheritedColorLookup(color))
+ ) {
+ variables[INHERITED_COLOR_VARIABLE] = color;
+ }
+}