diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx
index b08e24c3..807a3f26 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,700 @@ 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}; }`;
+
+ // css-variables-1 §2: the keyword is the custom property's own value, which a read then substitutes
+ test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])(
+ "--brand: %s compiles to the parent's value",
+ (keyword) => {
+ const css = uninlinedCustomProperty(keyword);
+
+ expect(publishedVariable(css, "brand")).toEqual([
+ [{}, "inherit"],
+ [{}, "inherit"],
+ ]);
+ expect(compile(css).warnings()).toStrictEqual({});
+ },
+ );
+
+ test("whitespace around the keyword leaves it the whole value", () => {
+ expect(
+ publishedVariable(uninlinedCustomProperty(" inherit "), "brand"),
+ ).toEqual([
+ [{}, "inherit"],
+ [{}, "inherit"],
+ ]);
+ });
+
+ test("--brand: initial compiles to the guaranteed-invalid value", () => {
+ const css = uninlinedCustomProperty("initial");
+
+ expect(publishedVariable(css, "brand")).toEqual([
+ [{}, "initial"],
+ [{}, "initial"],
+ ]);
+ expect(compile(css).warnings()).toStrictEqual({});
+ });
+
+ 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([
+ [{}, "inherit"],
+ [{}, "inherit"],
+ ]);
+ });
+
+ 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 set to inherit is never inlined, because its value is its parent's", () => {
+ expect(
+ publishedVariable(
+ `.child { --brand: inherit; color: var(--brand); }`,
+ "brand",
+ ),
+ ).toEqual([[{}, "inherit"]]);
+ });
+
+ test("a variable that leads into a cycle is inlined, and only the cycle's members are left to the runtime", () => {
+ const rule = compile(
+ `.child { --lead: var(--cycle-x); --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); color: var(--lead); }`,
+ ).stylesheet().s?.[0]?.[1]?.[0];
+
+ expect((rule?.v ?? []).map(([name]) => name)).toEqual(
+ expect.arrayContaining(["cycle-x", "cycle-y"]),
+ );
+ expect(JSON.stringify(rule?.d)).toContain('"cycle-x"');
+ expect(JSON.stringify(rule?.d)).not.toContain('"lead"');
+ });
+
+ test("a variable two branches read is inlined into both, as no cycle", () => {
+ expect(
+ declarationsFor(
+ `.child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }`,
+ ),
+ ).toStrictEqual(declarationsFor(`.child { width: 6px; }`));
+ });
+
+ test("a custom property set to initial is inlined as the guaranteed-invalid value, so a read takes its fallback", () => {
+ expect(
+ declarationsFor(`.child { --brand: initial; color: var(--brand, red); }`),
+ ).toStrictEqual(declarationsFor(`.child { color: red; }`));
+ });
+
+ test("a read of an inlined initial with no fallback is invalid at computed-value time, so it compiles as unset", () => {
+ expect(
+ declarationsFor(`.child { --brand: initial; width: var(--brand); }`),
+ ).toStrictEqual(declarationsFor(`.child { width: unset; }`));
+ expect(
+ declarationsFor(`.child { --brand: initial; color: var(--brand); }`),
+ ).toStrictEqual(declarationsFor(`.child { color: unset; }`));
+ });
+
+ 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);
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an argument its function cannot take
+describe("a written filter the property cannot take", () => {
+ test.each(["brightness(-1)", "blur(-1px)", "blur(2px) brightness(-0.5)"])(
+ "filter: %s emits no declaration",
+ (value) => {
+ expect(
+ compile(`.child { filter: ${value}; }`).stylesheet(),
+ ).toStrictEqual({});
+ },
+ );
+
+ test("CONTROL: a url() entry is left out and the filters React Native can draw still apply", () => {
+ expect(
+ compile(`.child { filter: blur(2px) url(#shadow); }`).stylesheet()
+ .s?.[0]?.[1]?.[0]?.d,
+ ).toStrictEqual([{ filter: [{ blur: 2 }] }]);
+ });
+
+ test("CONTROL: a percentage and a non-negative number are kept as written", () => {
+ expect(
+ compile(`.child { filter: brightness(150%) contrast(0); }`).stylesheet()
+ .s?.[0]?.[1]?.[0]?.d,
+ ).toStrictEqual([{ filter: [{ brightness: "150%" }, { contrast: 0 }] }]);
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone
+describe("an angle in gradians or turns", () => {
+ test.each([
+ ["hue-rotate(1turn)", "360deg"],
+ ["hue-rotate(100grad)", "90deg"],
+ ])("filter: %s compiles to %s", (filter, degrees) => {
+ const compiled = compile(`.child { filter: ${filter}; }`);
+
+ expect(compiled.warnings()).toStrictEqual({});
+ expect(compiled.stylesheet().s?.[0]?.[1]?.[0]?.d).toStrictEqual([
+ { filter: [{ hueRotate: degrees }] },
+ ]);
+ });
+
+ test.each(["transform: rotate(100grad)", "rotate: 0.25turn"])(
+ "%s compiles without a warning",
+ (declaration) => {
+ expect(compile(`.child { ${declaration}; }`).warnings()).toStrictEqual(
+ {},
+ );
+ },
+ );
+});
diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx
index c9565d16..811fe3ab 100644
--- a/src/__tests__/native/animations.test.tsx
+++ b/src/__tests__/native/animations.test.tsx
@@ -133,6 +133,80 @@ test("an invalid numeric animation name is omitted without throwing", () => {
expect(style()?.animationName).toBeUndefined();
});
+const variableAnimation = `.other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); }`;
+const clearedAnimation = {
+ animationName: undefined,
+ animationDelay: undefined,
+ animationDirection: undefined,
+ animationDuration: undefined,
+ animationFillMode: undefined,
+ animationIterationCount: undefined,
+ animationPlayState: undefined,
+ animationTimingFunction: undefined,
+};
+
+// Chromium 153, Firefox 155 and WebKit 26.6 reset every animation longhand for `initial`, `unset` and a var() that resolves to nothing
+describe.each([undefined, false] as const)(
+ "an animation reset with inlineVariables=%s",
+ (inlineVariables) => {
+ test.each(["initial", "unset", "var(--missing)"])(
+ "animation: %s after a written animation leaves no animation",
+ (reset) => {
+ registerCSS(
+ `${keyframes} .earlier { animation: slide-in 1s; } .reset { animation: ${reset}; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(
+ clearedAnimation,
+ );
+ },
+ );
+
+ test.each(["initial", "unset", "var(--missing)"])(
+ "animation: %s after a variable animation leaves no animation",
+ (reset) => {
+ registerCSS(
+ `${keyframes} ${variableAnimation} .reset { animation: ${reset}; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(
+ clearedAnimation,
+ );
+ },
+ );
+
+ test.each(["initial", "var(--missing)"])(
+ "animation-name: %s after a written animation name leaves no animation name",
+ (reset) => {
+ registerCSS(
+ `${keyframes} .earlier { animation-name: slide-in; animation-duration: 1s; } .reset { animation-name: ${reset}; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ animationName: undefined,
+ animationDuration: [1000],
+ });
+ },
+ );
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it
+ test("a written animation-duration after a variable animation applies", () => {
+ registerCSS(
+ `${keyframes} ${variableAnimation} .reset { animation-duration: 2s; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(style()).toMatchObject({
+ animationName: horizontal,
+ animationDuration: [2000],
+ });
+ });
+ },
+);
+
test("multiple animation names preserve keyframe order and timing metadata", () => {
registerCSS(`${keyframes} .motion {
animation: slide-in 1s linear 250ms 2 alternate both,
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/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx
new file mode 100644
index 00000000..17c6e5cb
--- /dev/null
+++ b/src/__tests__/native/deferred-declarations.test.tsx
@@ -0,0 +1,258 @@
+import { render } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { registerCSS, testID } from "react-native-css/jest";
+
+// A second definition keeps each variable from being inlined, so it resolves at runtime
+const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`;
+
+function renderText(css: string, className = "my-class") {
+ registerCSS(`${decoy} ${css}`);
+
+ return render().getByTestId(
+ testID,
+ ).props;
+}
+
+// A declaration that reads a variable resolves against the target it was declared on
+describe("a deferred declaration beside a nested one", () => {
+ test("color: var() beside a box-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ boxShadow: [
+ {
+ color: "#00f",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+ });
+
+ test("color: var() beside a text-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ textShadowColor: "#00f",
+ textShadowRadius: 2,
+ textShadowOffset: { width: 1, height: 1 },
+ });
+ });
+
+ test("color: var() beside a prop", () => {
+ const props = renderText(
+ `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+
+ test("a deferred prop beside a style", () => {
+ const props = renderText(
+ `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+
+ test("a text-shadow with no colour takes the element's colour", () => {
+ expect(
+ renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style,
+ ).toStrictEqual({
+ color: "#00f",
+ textShadowColor: "#00f",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 2, height: 2 },
+ });
+ });
+
+ test("a box-shadow's first shadow with no colour takes the inherited colour", () => {
+ registerCSS(
+ `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`,
+ );
+
+ const child = render(
+
+
+ ,
+ ).getByTestId(testID);
+
+ expect(child.props.style).toStrictEqual({
+ boxShadow: [
+ {
+ color: "#123456",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ inset: true,
+ },
+ {
+ color: "#654321",
+ offsetX: 2,
+ offsetY: 2,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves
+describe("a deferred shorthand keeps no key a later declaration set", () => {
+ test.each([
+ [
+ "border: var() then border-color, in one rule",
+ ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), in one rule",
+ ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border: var() then border-color, across rules",
+ ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), across rules",
+ ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border-inline-width: var() then border-inline-start-width",
+ ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }",
+ "my-class",
+ { borderStartWidth: 5, borderEndWidth: 3 },
+ ],
+ [
+ "border-inline-start-width then border-inline-width: var()",
+ ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }",
+ "my-class",
+ { borderStartWidth: 2, borderEndWidth: 3 },
+ ],
+ [
+ "text-shadow: var() then text-shadow, across rules",
+ ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }",
+ "earlier my-class",
+ {
+ textShadowColor: "#f00",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 1, height: 1 },
+ },
+ ],
+ [
+ "text-shadow then text-shadow: var(), across rules",
+ ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }",
+ "earlier my-class",
+ {
+ textShadowColor: "#00f",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 2, height: 2 },
+ },
+ ],
+ ] as const)("%s", (_name, css, className, style) => {
+ expect(renderText(css, className).style).toStrictEqual(style);
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written
+test("a deferred transform composes with an individual transform property as a written one does", () => {
+ registerCSS(
+ `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`,
+ );
+
+ const screen = render(
+ <>
+
+
+ >,
+ );
+
+ const written = screen.getByTestId("written").props.style as {
+ transform: unknown[];
+ };
+ expect(written.transform).toEqual(
+ expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]),
+ );
+ expect(screen.getByTestId("held").props.style).toStrictEqual(written);
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone
+test("a var() with no value resets an earlier rule's value", () => {
+ expect(
+ renderText(
+ ".earlier { width: 10px; } .my-class { width: var(--c-missing); }",
+ "earlier my-class",
+ ).style,
+ ).toStrictEqual({ width: undefined });
+});
+
+// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands
+test("a written value the runtime cannot read leaves an earlier rule's value", () => {
+ expect(
+ renderText(
+ ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }",
+ "earlier my-class",
+ ).style,
+ ).toStrictEqual({ width: 10 });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it
+test.each([
+ [
+ "before a transform",
+ ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }",
+ ],
+ [
+ "after a transform",
+ ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }",
+ ],
+])(
+ "an individual transform property declared %s composes with it",
+ (_order, css) => {
+ const { transform } = renderText(css, "earlier my-class").style as {
+ transform: unknown[];
+ };
+
+ expect(transform).toHaveLength(2);
+ expect(transform).toEqual(
+ expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]),
+ );
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property
+test("an individual transform property beside a box-shadow", () => {
+ expect(
+ renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style,
+ ).toStrictEqual({
+ transform: [{ rotateZ: "45deg" }],
+ boxShadow: [
+ {
+ color: "#00f",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+});
diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx
index 0e7a2840..8d8f4918 100644
--- a/src/__tests__/native/filter-list-runtime.test.tsx
+++ b/src/__tests__/native/filter-list-runtime.test.tsx
@@ -3,6 +3,7 @@ import { StyleSheet } from "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 { VariableContextProvider } from "react-native-css/native";
const firstShadow = {
dropShadow: { offsetX: 0, offsetY: 1, standardDeviation: 2, color: "#000" },
@@ -58,6 +59,145 @@ 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();
+ },
+ );
+
+ // Chromium 153, Firefox 155 and WebKit 26.6: an empty fallback substitutes nothing, while an undefined variable with none invalidates the list
+ test("an empty fallback contributes no filter", () => {
+ registerCSS(`.subject { filter: var(--missing,) blur(2px); }`, {
+ inlineVariables,
+ });
+ render();
+ expect(filter()).toEqual([{ blur: 2 }]);
+ });
+
+ test("an undefined variable with no fallback leaves no filter", () => {
+ registerCSS(
+ `.earlier { filter: blur(4px); } .subject { filter: var(--missing) blur(2px); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toBeUndefined();
+ });
+
+ test("an undefined variable that is the whole value leaves no filter", () => {
+ registerCSS(
+ `.earlier { filter: blur(4px); } .subject { filter: var(--missing); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toBeUndefined();
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an entry the property cannot take, so the earlier filter stands
+ test.each([
+ "blur(2px) drop-shadow(2px)",
+ "drop-shadow(2px)",
+ "blur(foo) brightness(2)",
+ "brightness(-1)",
+ "blur(-1px)",
+ "hue-rotate(90)",
+ "blur(2px 3px)",
+ "none blur(2px)",
+ "blur(2px) none",
+ "drop-shadow(1px 2px 3px 4px red)",
+ ])("filter: %s as written is ignored", (written) => {
+ registerCSS(
+ `.earlier { filter: blur(4px); } .written { filter: ${written}; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toEqual([{ blur: 4 }]);
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter where a variable holds one, as the declaration is invalid at computed-value time
+ test.each([
+ "blur(foo)",
+ "blur(2px) drop-shadow(2px)",
+ "brightness(-1)",
+ "blur(2px 3px)",
+ "drop-shadow(1px 2px 3px 4px red)",
+ ])("filter: var(--effect) with --effect: %s leaves no filter", (held) => {
+ registerCSS(
+ `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .subject { --effect: ${held}; filter: var(--effect); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toBeUndefined();
+ });
+
+ test("a rejected written filter hands back to the variable filter it followed", () => {
+ registerCSS(
+ `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .middle { --effect: brightness(2); filter: var(--effect); } .written { filter: blur(foo); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toEqual([{ brightness: 2 }]);
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 compute hue-rotate(360deg) and hue-rotate(90deg), and React Native takes degrees or radians alone
+ test.each([
+ ["1turn", "360deg"],
+ ["100grad", "90deg"],
+ ["-90DEG", "-90deg"],
+ ])(
+ "hue-rotate(%s) supplied at render reaches React Native as %s",
+ (angle, degrees) => {
+ registerCSS(`.subject { filter: hue-rotate(var(--angle)); }`, {
+ inlineVariables,
+ });
+ render(
+
+
+ ,
+ );
+ expect(filter()).toEqual([{ hueRotate: degrees }]);
+ },
+ );
+
+ test.each([
+ ["1turn", "360deg"],
+ ["100grad", "90deg"],
+ ])(
+ "hue-rotate(%s) a stylesheet variable holds reaches React Native as %s",
+ (angle, degrees) => {
+ registerCSS(
+ `.other { --effect: blur(1px); } .subject { --effect: hue-rotate(${angle}); filter: var(--effect); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toEqual([{ hueRotate: degrees }]);
+ },
+ );
+
+ test("CONTROL: a variable holding filters the property takes keeps them all", () => {
+ registerCSS(
+ `.other { --effect: blur(1px); } .subject { --effect: blur(2px) brightness(150%); filter: var(--effect); }`,
+ { inlineVariables },
+ );
+ render();
+ expect(filter()).toEqual([{ blur: 2 }, { brightness: "150%" }]);
+ });
},
);
diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx
index f1b3defc..40ea97f5 100644
--- a/src/__tests__/native/keywords.test.tsx
+++ b/src/__tests__/native/keywords.test.tsx
@@ -1,17 +1,658 @@
-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)", { transform: undefined }],
+ ["line-height", "30px", { lineHeight: undefined }],
+ [
+ "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();
+
+ return screen.getByTestId("child").props.style;
+}
+
+// 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(
+
+
+ ,
+ );
- const { result } = renderHook(() => {
- return useNativeCss(View, { className: "my-class" });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
+ });
+ },
+);
+
+const routes = [
+ ["inlined", {}],
+ ["kept", { inlineVariables: false }],
+] as const;
+
+function renderNested(
+ css: string,
+ options: (typeof routes)[number][1],
+ childClassName = "child",
+): unknown {
+ registerCSS(css, options);
+
+ render(
+
+
+ ,
+ );
+
+ return screen.getByTestId("child").props.style;
+}
+
+// Chromium 153, Firefox 155 and WebKit 26.6 resolve the keyword on the custom property itself, so it wins over an earlier rule's value (css-variables-1 §2)
+test.each(["inherit", "unset", "revert", "revert-layer"])(
+ "--brand: %s over an earlier rule reads the ancestor's --brand",
+ (keyword) => {
+ expect(
+ renderNested(
+ `
+ .parent { --brand: green; }
+ .earlier { --brand: blue; }
+ .child { --brand: ${keyword}; color: var(--brand); }
+ `,
+ {},
+ "earlier child",
+ ),
+ ).toStrictEqual({ color: "green" });
+ },
+);
+
+test("--brand: inherit over an earlier rule hands the subtree the ancestor's --brand", () => {
+ registerCSS(`
+ .parent { --brand: green; }
+ .earlier { --brand: blue; }
+ .middle { --brand: inherit; }
+ .child { color: var(--brand); }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
});
+});
+
+test("--brand: inherit with no ancestor --brand hands the subtree the guaranteed-invalid value", () => {
+ registerCSS(`
+ .earlier { --brand: blue; }
+ .middle { --brand: inherit; }
+ .child { color: var(--brand, green); }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
- expect(result.current.props).toStrictEqual({
- style: { backgroundColor: undefined },
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
});
});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the parent too, so it is the value the parent hands down
+test("--parent-own: inherit under no declaring ancestor reads what * declares", () => {
+ expect(
+ renderNested(
+ `
+ * { --parent-own: red; }
+ .child { --parent-own: inherit; color: var(--parent-own, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "red" });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property set to initial the guaranteed-invalid value (css-variables-1 §2.2), so a var() reading it takes its fallback
+describe.each(routes)("--brand: initial, %s", (_route, options) => {
+ test("takes the var() fallback over an ancestor's --brand", () => {
+ expect(
+ renderNested(
+ `
+ .parent { --brand: blue; }
+ .child { --brand: initial; color: var(--brand, green); }
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "green" });
+ });
+
+ test("makes a custom property reading it the guaranteed-invalid value too", () => {
+ expect(
+ renderNested(
+ `
+ .child { --source: initial; --reader: var(--source); color: var(--reader, green); }
+ .other { --reader: 1px; }
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "green" });
+ });
+
+ test("hides the ancestor's --brand from a descendant", () => {
+ registerCSS(
+ `
+ .parent { --brand: blue; }
+ .middle { --brand: initial; }
+ .child { color: var(--brand, green); }
+ `,
+ options,
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
+ });
+ });
+
+ test("hides a :root declaration from a descendant", () => {
+ registerCSS(
+ `
+ :root { --hidden-root: red; }
+ .middle { --hidden-root: initial; }
+ .child { color: var(--hidden-root, green); }
+ .other { --hidden-root: blue; }
+ `,
+ options,
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
+ });
+ });
+
+ test("over an earlier rule makes var(--brand) invalid, so the colour inherits", () => {
+ expect(
+ renderNested(
+ `
+ .parent { color: green; --brand: red; }
+ .earlier { --brand: blue; }
+ .child { --brand: initial; color: var(--brand); }
+ `,
+ options,
+ "earlier child",
+ ),
+ ).toStrictEqual({ color: "#008000" });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property its var() leaves without a value the guaranteed-invalid value (css-variables-1 §3.1), so a var() reading it takes its fallback
+describe.each(routes)(
+ "a custom property a var() leaves without a value, %s",
+ (_route, options) => {
+ test("takes the var() fallback over an ancestor's declaration", () => {
+ expect(
+ renderNested(
+ `
+ .parent { --unresolved: blue; }
+ .child { --unresolved: var(--missing); color: var(--unresolved, green); }
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "green" });
+ });
+
+ test("hides the ancestor's declaration from a descendant", () => {
+ registerCSS(
+ `
+ .parent { --unresolved-down: blue; }
+ .middle { --unresolved-down: var(--missing); }
+ .child { color: var(--unresolved-down, green); }
+ `,
+ options,
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "green",
+ });
+ });
+
+ test("reads the guaranteed-invalid value inside a longer value", () => {
+ registerCSS(
+ `.child { --source: initial; --reader: var(--source) 1px; width: var(--reader, 5px); }`,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ width: 5,
+ });
+ });
+
+ test("reads it inside a function declared before the variable it reads", () => {
+ registerCSS(
+ `.child { --reader: calc(var(--source) * 2); --source: initial; width: var(--reader, 5px); }`,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ width: 5,
+ });
+ });
+
+ test.each([
+ ["declared once", ""],
+ ["declared twice", ".other { --reader: 1px; }"],
+ ])(
+ "an empty value substituted whole lets the colour inherit, the variable %s",
+ (_declarations, other) => {
+ expect(
+ renderNested(
+ `
+ .parent { color: green; }
+ .child { --source: initial; --reader: var(--source,); color: var(--reader, red); }
+ ${other}
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "#008000" });
+ },
+ );
+
+ test("a value whose every var() substitutes nothing clears an earlier rule's value", () => {
+ registerCSS(
+ `
+ .earlier { margin: 5px; }
+ .child { --empty-start: initial; --empty-end: initial; margin: var(--empty-start,) var(--empty-end,); }
+ .other { --empty-end: 1px; }
+ `,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ margin: undefined,
+ });
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 substitute an empty custom property's value as nothing, over an ancestor's
+ test("an authored empty value shadows the ancestor's, and lets the colour inherit", () => {
+ expect(
+ renderNested(
+ `
+ .parent { color: green; --authored-empty: red; }
+ .child { --authored-empty: ; color: var(--authored-empty, blue); }
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "#008000" });
+ });
+
+ test("an authored empty value substitutes nothing inside a longer value", () => {
+ registerCSS(
+ `.child { --authored-empty-list: ; width: calc(4px var(--authored-empty-list) + 2px); }`,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ width: 6,
+ });
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 unset both, reading neither the fallback nor the parent's declaration
+ test("a value that resolves to nothing reads the same each time it is read", () => {
+ registerCSS(
+ `
+ .parent { --memo: 9px; }
+ .child { --memo-empty: initial; --memo: calc(var(--memo-empty,) * 2); width: var(--memo, 5px); height: var(--memo, 5px); }
+ .other { --memo: 1px; --memo-empty: 1px; }
+ `,
+ options,
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ width: undefined,
+ height: undefined,
+ });
+ });
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6: the root has no parent, so inherit gives the guaranteed-invalid value there too
+test.each(["inherit", "initial"])(
+ ":root { --brand: %s } leaves var(--brand) its fallback",
+ (keyword) => {
+ expect(
+ renderNested(
+ `
+ :root { --root-brand: ${keyword}; }
+ .other { --root-brand: blue; }
+ .child { color: var(--root-brand, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "green" });
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the element itself, below its classes
+test("* { --brand: inherit } reads the parent's --brand", () => {
+ expect(
+ renderNested(
+ `
+ .parent { --universal-inherit: blue; }
+ * { --universal-inherit: inherit; }
+ .child { color: var(--universal-inherit, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "blue" });
+});
+
+test("* { --brand: initial } leaves var(--brand) its fallback", () => {
+ expect(
+ renderNested(
+ `
+ * { --universal-initial: initial; }
+ .other { --universal-initial: blue; }
+ .child { color: var(--universal-initial, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "green" });
+});
+
+test("--brand: inherit under *'s initial leaves var(--brand) its fallback", () => {
+ expect(
+ renderNested(
+ `
+ * { --universal-gone: initial; }
+ .child { --universal-gone: inherit; color: var(--universal-gone, green); }
+ .other { --universal-gone: blue; }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "green" });
+});
+
+test("* { --universal-over-ancestor: initial } hides an ancestor's declaration", () => {
+ expect(
+ renderNested(
+ `
+ .parent { --universal-over-ancestor: blue; }
+ * { --universal-over-ancestor: initial; }
+ .child { color: var(--universal-over-ancestor, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "green" });
+});
+
+test("a class's --brand: inherit wins over *'s, and reads the parent's", () => {
+ expect(
+ renderNested(
+ `
+ .parent { --class-over-universal: blue; }
+ * { --class-over-universal: red; }
+ .child { --class-over-universal: inherit; color: var(--class-over-universal, green); }
+ `,
+ {},
+ ),
+ ).toStrictEqual({ color: "blue" });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 make a declaration whose var() resolves to nothing unset (css-variables-1 §3.1): color inherits, the others reset to their initial value, which React Native's default matches
+describe.each(routes)(
+ "a var() that resolves to nothing, %s",
+ (_route, options) => {
+ test("--brand: inherit with no ancestor --brand: the colour inherits", () => {
+ expect(
+ renderNested(
+ `
+ .parent { color: red; }
+ .child { --brand: inherit; color: var(--brand); }
+ `,
+ options,
+ ),
+ ).toStrictEqual({ color: "#f00" });
+ });
+
+ test("color over an earlier rule inherits", () => {
+ expect(
+ renderNested(
+ `
+ .parent { color: green; }
+ .earlier { color: red; }
+ .child { color: var(--missing); }
+ `,
+ options,
+ "earlier child",
+ ),
+ ).toStrictEqual({ color: "#008000" });
+ });
+
+ test.each(clearedProperties)(
+ "%s clears an earlier rule's value",
+ (property, earlier, cleared) => {
+ registerCSS(
+ `.earlier { ${property}: ${earlier}; } .child { ${property}: var(--missing); }`,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual(cleared);
+ },
+ );
+
+ test("an empty fallback as the whole value inherits the colour", () => {
+ expect(
+ renderNested(
+ `
+ .parent { color: green; }
+ .earlier { color: red; }
+ .child { color: var(--missing,); }
+ `,
+ options,
+ "earlier child",
+ ),
+ ).toStrictEqual({ color: "#008000" });
+ });
+
+ test("width: calc() of it clears an earlier rule's width", () => {
+ registerCSS(
+ `.earlier { width: 50px; } .child { width: calc(var(--missing) * 2); }`,
+ options,
+ );
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ width: undefined,
+ });
+ });
+ },
+);
+
+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..25db8c9e 100644
--- a/src/__tests__/native/transform.test.tsx
+++ b/src/__tests__/native/transform.test.tsx
@@ -113,11 +113,12 @@ describe("transform", () => {
});
});
+ // Chromium 153, Firefox 155 and WebKit 26.6: Tailwind's empty fallbacks let an undefined variable substitute nothing
test("rotate-x-45", () => {
registerCSS(`
.rotate-45 {
--tw-rotate-x: rotateX(45deg);
- transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}`);
const component = render(
@@ -154,3 +155,112 @@ 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({ transform: undefined });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written transform list with a keyword among its functions, so the earlier transform stands
+test("transform: none rotate(10deg) as written is ignored", () => {
+ registerCSS(
+ `.earlier { transform: translateX(10px); } .written { transform: none rotate(10deg); }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 10 }],
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 substitute each transform a variable holds, in place
+test("a var() holding several transforms substitutes each of them", () => {
+ registerCSS(
+ `.my-class { --several: rotate(45deg) scale(2); transform: translateX(1px) var(--several); } .other { --several: none; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 1 }, { rotate: "45deg" }, { scale: 2 }],
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone
+test.each([
+ ["transform: rotate(100grad)", { transform: [{ rotate: "90deg" }] }],
+ ["rotate: 0.25turn", { transform: [{ rotateZ: "90deg" }] }],
+ ["transform: skewX(50grad)", { transform: [{ skewX: "45deg" }] }],
+])("%s reaches React Native in degrees", (declaration, style) => {
+ registerCSS(`.my-class { ${declaration}; }`);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual(style);
+});
diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx
index e61340b5..3ebad172 100644
--- a/src/__tests__/native/variables.test.tsx
+++ b/src/__tests__/native/variables.test.tsx
@@ -271,3 +271,111 @@ test("variable overriding with classes", () => {
const component = screen.getByTestId(testID);
expect(component.props.style).toStrictEqual({ color: "#f00" });
});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 read a custom property's name as its own, never as a property every object inherits
+test.each(["constructor", "toString", "hasOwnProperty"])(
+ "var(--%s) with no declaration takes its fallback",
+ (name) => {
+ registerCSS(`
+ .parent { --unrelated: 1px; }
+ .other { --unrelated: 2px; }
+ .test { color: var(--${name}, green); }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: "green",
+ });
+ },
+);
+
+test("var(--constructor) reads the ancestor's declaration", () => {
+ registerCSS(`
+ .parent { --constructor: blue; }
+ .other { --constructor: red; }
+ .test { color: var(--constructor); }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: "blue",
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 make each member of a cycle the guaranteed-invalid value (css-variables-1 §2.3), wherever it is declared
+test.each([
+ ["*", "universal-cycle"],
+ [":root", "root-cycle"],
+])("a cycle declared on %s leaves var() its fallback", (selector, name) => {
+ registerCSS(`
+ ${selector} { --${name}-a: var(--${name}-b); --${name}-b: var(--${name}-a); }
+ .other { --${name}-a: 1px; --${name}-b: 1px; }
+ .test { color: var(--${name}-a, green); }
+ `);
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: "green",
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 compute a variable read inside a function from the variable's own computed value, wherever either is declared
+test.each([
+ [
+ "after",
+ ".child { --order-source: 3px; --order-middle: var(--order-source); --order-reader: calc(var(--order-middle) * 2); width: var(--order-reader, 5px); }",
+ ],
+ [
+ "before",
+ ".child { --order-reader: calc(var(--order-middle) * 2); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader, 5px); }",
+ ],
+ [
+ "inside an undeclared variable's fallback, before",
+ ".child { --order-reader: var(--order-absent, calc(var(--order-middle) * 2)); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader); }",
+ ],
+])(
+ "a variable read inside a function declared %s the chain it reads resolves through it",
+ (_order, css) => {
+ registerCSS(css);
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 6 });
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6 give every member of a cycle the guaranteed-invalid value however few rules declare it, and resolve a variable two branches read in both
+test.each([
+ [
+ "a cycle declared once leaves var() its fallback",
+ ".child { --cycle-once-a: var(--cycle-once-b); --cycle-once-b: var(--cycle-once-a); color: var(--cycle-once-b, green); }",
+ { color: "green" },
+ ],
+ [
+ "a cycle read twice in one declaration takes each fallback",
+ ".child { --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); width: calc(var(--cycle-x, 2px) + var(--cycle-x, 3px)); } .other { --cycle-x: 1px; --cycle-y: 1px; }",
+ { width: 5 },
+ ],
+ [
+ "a variable two branches read is no cycle",
+ ".child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }",
+ { width: 6 },
+ ],
+])("%s", (_name, css, style) => {
+ registerCSS(css);
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(style);
+});
diff --git a/src/__tests__/utilities/angle.test.ts b/src/__tests__/utilities/angle.test.ts
new file mode 100644
index 00000000..4aa512fa
--- /dev/null
+++ b/src/__tests__/utilities/angle.test.ts
@@ -0,0 +1,29 @@
+import { readAngle } from "react-native-css/utilities";
+
+describe("readAngle", () => {
+ test.each([
+ [0, 0],
+ ["90deg", "90deg"],
+ ["-90DEG", "-90deg"],
+ ["1rad", "1rad"],
+ ["1turn", "360deg"],
+ ["-0.5turn", "-180deg"],
+ ["100grad", "90deg"],
+ ["1e-1turn", "36deg"],
+ [".5rad", ".5rad"],
+ ])("%s reaches React Native as %s", (angle, expected) => {
+ expect(readAngle(angle)).toBe(expected);
+ });
+
+ test.each([90, "90", "90px", "deg", "foo", null, undefined])(
+ "%s is no angle",
+ (argument) => {
+ expect(readAngle(argument)).toBeUndefined();
+ },
+ );
+
+ test("the reading is idempotent", () => {
+ const once = readAngle("1turn");
+ expect(readAngle(once)).toBe(once);
+ });
+});
diff --git a/src/__tests__/utilities/filter-arguments.test.ts b/src/__tests__/utilities/filter-arguments.test.ts
new file mode 100644
index 00000000..d5397ef8
--- /dev/null
+++ b/src/__tests__/utilities/filter-arguments.test.ts
@@ -0,0 +1,33 @@
+import {
+ readNonNegativeAmount,
+ readNonNegativeLength,
+} from "react-native-css/utilities";
+
+describe("readNonNegativeLength", () => {
+ test.each([0, 2, 0.5])("%s is a length blur takes", (length) => {
+ expect(readNonNegativeLength(length)).toBe(length);
+ });
+
+ test.each([-1, "2px", "50%", "foo", null, undefined])(
+ "%s is no length blur takes",
+ (argument) => {
+ expect(readNonNegativeLength(argument)).toBeUndefined();
+ },
+ );
+});
+
+describe("readNonNegativeAmount", () => {
+ test.each([0, 1.5, "0%", "150%", ".5%", "+50%", "1e2%"])(
+ "%s is an amount a filter takes",
+ (amount) => {
+ expect(readNonNegativeAmount(amount)).toBe(amount);
+ },
+ );
+
+ test.each([-0.5, "-10%", "-1e2%", "2px", "foo", "%", "5.%", null, undefined])(
+ "%s is no amount a filter takes",
+ (argument) => {
+ expect(readNonNegativeAmount(argument)).toBeUndefined();
+ },
+ );
+});
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/compiler.types.ts b/src/compiler/compiler.types.ts
index 00e08785..d063df44 100644
--- a/src/compiler/compiler.types.ts
+++ b/src/compiler/compiler.types.ts
@@ -159,11 +159,14 @@ export type InlineVariable = {
[key: string]: unknown | undefined;
};
-export type UniqueVarInfo = {
- count: number;
- value: TokenOrValue[] | undefined;
- flat?: true;
-};
+/** A custom property's value as declared, until flattening settles what it substitutes: tokens, or undefined for the guaranteed-invalid value */
+export type UniqueVarInfo =
+ | { readonly flat?: undefined; count: number; value: TokenOrValue[] }
+ | {
+ readonly flat: true;
+ count: number;
+ value: TokenOrValue[] | undefined;
+ };
/****************************** Animations ******************************/
diff --git a/src/compiler/css-wide-keywords.ts b/src/compiler/css-wide-keywords.ts
new file mode 100644
index 00000000..26d49978
--- /dev/null
+++ b/src/compiler/css-wide-keywords.ts
@@ -0,0 +1,43 @@
+import type { TokenOrValue } from "lightningcss";
+
+const CSS_WIDE_KEYWORDS = [
+ "inherit",
+ "initial",
+ "unset",
+ "revert",
+ "revert-layer",
+] as const;
+
+export type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number];
+
+export function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword {
+ return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword);
+}
+
+export interface SoleCSSWideKeyword {
+ readonly keyword: CSSWideKeyword;
+ readonly written: string;
+}
+
+// A CSS-wide keyword is a value only on its own (css-values-4 §2.1), and a custom property's value keeps the whitespace around it
+export function soleCSSWideKeyword(
+ tokens: readonly TokenOrValue[] | null | undefined,
+): SoleCSSWideKeyword | undefined {
+ const [token, ...rest] = (tokens ?? []).filter(
+ (part) => part.type !== "token" || part.value.type !== "white-space",
+ );
+
+ 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;
+}
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 9ca2aab8..05782b7c 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -11,6 +11,7 @@ import type {
Declaration,
DimensionPercentageFor_LengthValue,
EnvironmentVariable,
+ Filter,
FontSize,
FontStyle,
FontVariantCaps,
@@ -34,15 +35,31 @@ import type {
TokenOrValue,
Translate,
UnresolvedColor,
+ Variable,
} from "lightningcss";
-import { isStyleFunction } from "../utilities";
+import {
+ guaranteedInvalidValue,
+ INHERITED_COLOR_VARIABLE,
+ inheritedColorLookup,
+ inheritedValue,
+ isInheritedColorLookup,
+ isStyleFunction,
+ readAngle,
+ readNonNegativeAmount,
+ readNonNegativeLength,
+} from "../utilities";
import { isMappingProperty } from "./atRules";
import type {
StyleDescriptor,
StyleFunction,
StyleRule,
} from "./compiler.types";
+import {
+ isCSSWideKeyword,
+ soleCSSWideKeyword,
+ type SoleCSSWideKeyword,
+} from "./css-wide-keywords";
import { parseEasingFunction, parseIterationCount } from "./keyframes";
import { toRNProperty } from "./selectors";
import type { StylesheetBuilder } from "./stylesheet";
@@ -983,17 +1000,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,30 +1025,288 @@ 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);
+ }
+ }
+}
+
+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 (isCustomProperty) {
+ return resolved === "inherit" ? inheritedValue() : guaranteedInvalidValue();
+ } else if (resolved === "inherit" && property === "color") {
+ return inheritedColorLookup();
+ } else if (resolved === "initial") {
+ // 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);
+ // An empty fallback substitutes nothing, which a longer value can hold (css-variables-1 §3)
+ const fallback = isEmptyTokenList(variable.fallback)
+ ? []
+ : parseSubstitutionFallback(
+ variable.fallback,
+ builder,
+ property,
+ position,
+ allowAuto,
+ );
+
+ return [{}, "var", fallback === undefined ? name : [name, fallback], 1];
+}
+
+function isEmptyTokenList(tokens: TokenOrValue[] | null | undefined): boolean {
+ return (
+ Array.isArray(tokens) &&
+ tokens.every(
+ (token) => token.type === "token" && token.value.type === "white-space",
+ )
+ );
+}
+
+// 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(
declaration: Extract,
builder: StylesheetBuilder,
) {
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 +1330,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,
@@ -1064,6 +1350,12 @@ export function parseCustomDeclaration(
if (parsedFontVariant !== undefined) {
builder.addDescriptor(property, parsedFontVariant);
}
+ } else if (
+ property.startsWith("--") &&
+ isEmptyTokenList(declaration.value.value)
+ ) {
+ // css-variables-1 §2: a custom property's value may be empty, and substitutes nothing
+ builder.addDescriptor(property, []);
} else if (
validProperties.has(property) ||
property.startsWith("--") ||
@@ -1071,7 +1363,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 +1452,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 +1461,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 +1471,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 +1485,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 +1576,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 +1649,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":
@@ -1532,14 +1797,13 @@ export function parseAngle(angle: Angle | number, builder: StylesheetBuilder) {
return `${angle}deg`;
}
- switch (angle.type) {
- case "deg":
- case "rad":
- return `${angle.value}${angle.type}`;
- default:
- builder.addWarning("value", `${angle.value} ${angle.type}`);
- return undefined;
+ const reactNativeAngle = readAngle(`${angle.value}${angle.type}`);
+
+ if (reactNativeAngle === undefined) {
+ builder.addWarning("value", `${angle.value} ${angle.type}`);
}
+
+ return reactNativeAngle;
}
export function parseSizeDeclaration(
@@ -1643,17 +1907,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 +1950,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 +2931,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 +2941,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 +3089,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 +3542,7 @@ function parseObjectFit(
});
builder.addDescriptor(
"object-fit",
- parseUnparsed(declaration.value, builder, "object-fit"),
+ parseDeclarationValue(declaration.value, builder, "object-fit"),
);
}
@@ -3288,6 +3553,11 @@ function parseObjectPosition(
builder.addMapping({
"object-position": ["contentPosition"],
});
+
+ if (addCSSWideKeyword(declaration.value, builder, "object-position")) {
+ return;
+ }
+
builder.addDescriptor("object-position", [
{},
"join",
@@ -3299,6 +3569,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") {
@@ -3348,40 +3624,80 @@ function parseFilter(
return "unset";
}
- return declaration.value.value
- .map((value) => {
- switch (value.type) {
- case "opacity":
- case "blur":
- case "brightness":
- case "contrast":
- case "grayscale":
- case "invert":
- case "saturate":
- case "sepia":
- return {
- [value.type]: parseLength(value.value, builder),
- } as unknown as StyleDescriptor;
- case "hue-rotate":
- return {
- [toRNProperty(value.type)]: parseAngle(value.value, builder),
- } as unknown as StyleDescriptor;
- case "drop-shadow":
- return [
- {},
- toRNProperty(value.type),
- [
- parseLength(value.value.xOffset, builder),
- parseLength(value.value.yOffset, builder),
- parseLength(value.value.blur, builder),
- parseColor(value.value.color, builder),
- ],
- ] as unknown as StyleDescriptor;
- case "url":
- return;
- }
- })
- .filter((value) => value !== undefined);
+ const filters: StyleDescriptor[] = [];
+
+ for (const value of declaration.value.value) {
+ // React Native has no SVG filter reference, so a url() entry is left out and the rest still apply
+ if (value.type === "url") {
+ continue;
+ }
+
+ const filter = parseFilterFunction(value, builder);
+
+ // A filter function the property cannot take leaves the declaration ignored, as the runtime does
+ if (filter === undefined) {
+ return;
+ }
+
+ filters.push(filter);
+ }
+
+ return filters;
+}
+
+function parseFilterFunction(
+ value: Exclude,
+ builder: StylesheetBuilder,
+): StyleDescriptor | undefined {
+ switch (value.type) {
+ case "blur":
+ return parseFilterArgument(
+ value.type,
+ parseLength(value.value, builder),
+ readNonNegativeLength,
+ );
+ case "opacity":
+ case "brightness":
+ case "contrast":
+ case "grayscale":
+ case "invert":
+ case "saturate":
+ case "sepia":
+ return parseFilterArgument(
+ value.type,
+ parseLength(value.value, builder),
+ readNonNegativeAmount,
+ );
+ case "hue-rotate":
+ return parseFilterArgument(
+ toRNProperty(value.type),
+ parseAngle(value.value, builder),
+ readAngle,
+ );
+ case "drop-shadow":
+ return [
+ {},
+ toRNProperty(value.type),
+ [
+ parseLength(value.value.xOffset, builder),
+ parseLength(value.value.yOffset, builder),
+ parseLength(value.value.blur, builder),
+ parseColor(value.value.color, builder),
+ ],
+ ] as unknown as StyleDescriptor;
+ }
+}
+
+// A unit resolved at runtime is read there, by the same reader
+function parseFilterArgument(
+ name: string,
+ argument: StyleDescriptor | undefined,
+ read: (argument: unknown) => unknown,
+): StyleDescriptor | undefined {
+ const value = isStyleFunction(argument) ? argument : read(argument);
+ return value === undefined
+ ? undefined
+ : ({ [name]: value } as unknown as StyleDescriptor);
}
const namedColors = new Set([
diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts
index 609760c1..62811155 100644
--- a/src/compiler/inline-variables.ts
+++ b/src/compiler/inline-variables.ts
@@ -6,6 +6,7 @@ import type {
} from "lightningcss";
import type { UniqueVarInfo } from "./compiler.types";
+import { soleCSSWideKeyword } from "./css-wide-keywords";
export function inlineVariables(
stylesheet: StyleSheet,
@@ -118,13 +119,59 @@ function replaceDeclaration(
return;
}
- declaration.value.value = declaration.value.value.flatMap((part) => {
- return flattenPart(part, vars);
- });
+ const value = readsGuaranteedInvalid(declaration.value.value, vars)
+ ? undefined
+ : declaration.value.value.flatMap((part) => flattenPart(part, vars));
+
+ // css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, as if its value were `unset`, and a custom property's is the guaranteed-invalid value
+ if (
+ value === undefined ||
+ (declaration.property === "unparsed" && substitutesNothing(value))
+ ) {
+ declaration.value.value = [
+ keywordToken(declaration.property === "custom" ? "initial" : "unset"),
+ ];
+ return declaration;
+ }
+
+ declaration.value.value = value;
return declaration;
}
+function substitutesNothing(parts: readonly TokenOrValue[]): boolean {
+ return parts.every(
+ (part) => part.type === "token" && part.value.type === "white-space",
+ );
+}
+
+function readsGuaranteedInvalid(
+ parts: readonly TokenOrValue[],
+ vars: Map,
+): boolean {
+ return parts.some((part) => {
+ if (part.type === "var") {
+ const varInfo = vars.get(part.value.name.ident);
+
+ if (varInfo === undefined || varInfo.value !== undefined) {
+ return false;
+ }
+
+ return part.value.fallback
+ ? readsGuaranteedInvalid(part.value.fallback, vars)
+ : true;
+ } else if (part.type === "function") {
+ return readsGuaranteedInvalid(part.value.arguments, vars);
+ }
+
+ return false;
+ });
+}
+
+function keywordToken(keyword: "initial" | "unset"): TokenOrValue {
+ return { type: "token", value: { type: "ident", value: keyword } };
+}
+
function flattenPart(
part: TokenOrValue,
vars: Map,
@@ -159,51 +206,79 @@ function flattenPart(
function flattenVar(
name: string,
vars: Map,
- seen = new Set(),
+ path = new Set(),
) {
- if (seen.has(name)) {
- vars.delete(name);
+ if (path.has(name)) {
+ // css-variables-1 §2.3: every variable in a cycle is the guaranteed-invalid value, which the runtime's cycle guard answers
+ leaveCycleToRuntime(name, path, vars);
+ return;
}
- seen.add(name);
-
- let varInfo = vars.get(name);
+ const varInfo = vars.get(name);
if (!varInfo || varInfo.flat) {
return;
}
- let varInfoValue = varInfo.value?.flatMap((part) => {
- if (part.type === "var") {
- const name = part.value.name.ident;
+ const parts = varInfo.value;
- flattenVar(name, vars, seen);
+ // Each variable the value reads is flattened before the value substitutes it, whichever was declared first
+ path.add(name);
+ for (const readName of readVariableNames(parts)) {
+ flattenVar(readName, vars, path);
+ }
+ path.delete(name);
- const nestedVarInfo = vars.get(part.value.name.ident);
- if (nestedVarInfo?.value) {
- return nestedVarInfo.value;
- }
- }
- return flattenPart(part, vars);
- });
+ if (!vars.has(name)) {
+ return;
+ }
- // If the variable is shorthand for "initial", substitute it for undefined
- if (
- varInfoValue?.length === 2 &&
- varInfoValue[0]?.type === "token" &&
- varInfoValue[0].value.type === "ident" &&
- varInfoValue[0].value.value === "initial" &&
- varInfoValue[1]?.type === "token" &&
- varInfoValue[1].value.type === "white-space"
- ) {
- varInfoValue = undefined;
+ const keyword = soleCSSWideKeyword(parts)?.keyword;
+
+ if (keyword !== undefined && keyword !== "initial") {
+ // The parent's value, which only the runtime knows
+ vars.delete(name);
+ return;
}
- varInfo = {
- count: 1,
- flat: true,
- value: varInfoValue,
- };
+ // css-variables-1 §2.2, §3.1: `initial`, and a value that reads the guaranteed-invalid value, are the guaranteed-invalid value, which no var() substitutes
+ const value =
+ keyword === "initial" || readsGuaranteedInvalid(parts, vars)
+ ? undefined
+ : parts.flatMap((part) => flattenPart(part, vars));
+
+ vars.set(name, { count: 1, flat: true, value });
+}
+
+function leaveCycleToRuntime(
+ reentered: string,
+ path: ReadonlySet,
+ vars: Map,
+) {
+ let inCycle = false;
+
+ for (const name of path) {
+ inCycle ||= name === reentered;
+
+ if (inCycle) {
+ vars.delete(name);
+ }
+ }
+}
+
+// The names `flattenPart` substitutes: each var(), its fallback's, and those inside a function's arguments
+function readVariableNames(
+ parts: readonly TokenOrValue[],
+ names = new Set(),
+): ReadonlySet {
+ for (const part of parts) {
+ if (part.type === "var") {
+ names.add(part.value.name.ident);
+ readVariableNames(part.value.fallback ?? [], names);
+ } else if (part.type === "function") {
+ readVariableNames(part.value.arguments, names);
+ }
+ }
- vars.set(name, varInfo);
+ return names;
}
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..32ea4ae2 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,
+ assignSubtreeVariables,
+} 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,13 @@ 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 =
+ assignSubtreeVariables(variables, rule.v, inheritedVariables) ??
+ publishedColor;
}
if (rule.c) {
@@ -206,6 +215,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..2a133a7f 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -2,19 +2,32 @@
import type {
InlineVariable,
StyleDeclaration,
+ StyleDescriptor,
+ StyleFunction,
StyleRule,
} from "react-native-css/compiler";
-import { Specificity as S } from "react-native-css/utilities";
+import {
+ assignPublishedColor,
+ assignRuleVariables,
+ inheritedColorLookup,
+ isStyleFunction,
+ Specificity as S,
+} from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
-import { applyValue, getDeepPath } from "../objects";
+import { applyValue } from "../objects";
import {
VAR_SYMBOL,
type Getter,
type VariableContextValue,
} from "../reactivity";
import { transformKeys } from "./defaults";
-import { resolveValue } from "./resolve";
+import {
+ isEmptySubstitution,
+ resolveValue,
+ type ResolveValueOptions,
+ type Substitution,
+} from "./resolve";
export function calculateProps(
get: Getter,
@@ -30,9 +43,10 @@ export function calculateProps(
let normal: Record | undefined;
let important: Record | undefined;
- const delayedStyles: (() => void)[] = [];
const transformStyles: (() => void)[] = [];
+ let publishedColor: StyleDescriptor | undefined;
+ // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves
for (const rule of rules) {
if (VAR_SYMBOL in rule) {
if (typeof rule[VAR_SYMBOL] === "string") {
@@ -40,47 +54,44 @@ export function calculateProps(
} else {
Object.assign(inheritedVariables, rule);
}
- continue;
+ } else if (rule.v) {
+ publishedColor =
+ assignRuleVariables(inlineVariables, rule.v) ?? publishedColor;
}
+ }
- if (rule.v) {
- for (const variable of rule.v) {
- inlineVariables[variable[0]] = variable[1];
- }
+ assignPublishedColor(inlineVariables, publishedColor);
+
+ for (const rule of rules) {
+ if (VAR_SYMBOL in rule || !rule.d) {
+ continue;
}
- if (rule.d) {
- let topLevelTarget = rule.s?.[S.Important]
- ? (important ??= {})
- : (normal ??= {});
- let target = topLevelTarget;
+ let topLevelTarget = rule.s?.[S.Important]
+ ? (important ??= {})
+ : (normal ??= {});
+ let target = topLevelTarget;
- const ruleTarget = rule.target || "style";
+ const ruleTarget = rule.target || "style";
- if (typeof ruleTarget === "string") {
- target = target[ruleTarget] ??= {};
- } else if (ruleTarget) {
- for (const path of ruleTarget) {
- target = target[path] ??= {};
- }
+ if (typeof ruleTarget === "string") {
+ target = target[ruleTarget] ??= {};
+ } else if (ruleTarget) {
+ for (const path of ruleTarget) {
+ target = target[path] ??= {};
}
-
- applyDeclarations(
- get,
- rule.d,
- inlineVariables,
- inheritedVariables,
- delayedStyles,
- transformStyles,
- guards,
- target,
- topLevelTarget,
- );
}
- }
- for (const delayedStyle of delayedStyles) {
- delayedStyle();
+ applyDeclarations(
+ get,
+ rule.d,
+ inlineVariables,
+ inheritedVariables,
+ transformStyles,
+ guards,
+ target,
+ topLevelTarget,
+ );
}
for (const transformStyle of transformStyles) {
@@ -99,25 +110,26 @@ export function applyDeclarations(
declarations: StyleDeclaration[],
inlineVariables: InlineVariable,
inheritedVariables: VariableContextValue,
- delayedStyles: (() => void)[] = [],
transformStyles: (() => void)[] = [],
guards: RenderGuard[] = [],
target: Record = {},
topLevelTarget = target,
) {
- const originalTarget = target;
-
for (const declaration of declarations) {
- target = originalTarget;
+ // The 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 +143,7 @@ export function applyDeclarations(
if (final) {
if (first !== "&") {
topLevelTarget[first] ??= {};
- target = topLevelTarget[first];
+ declarationTarget = topLevelTarget[first];
}
let previousProp: string | number = first;
@@ -143,75 +155,121 @@ 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 {
prop = propPath;
}
- const shouldDelay = declaration[2];
-
- if (shouldDelay || transformKeys.has(prop)) {
- /**
- * We need to delay the resolution of this value until after all
- * styles have been calculated. But another style might override
- * this value. So we set a placeholder value and only override
- * if the placeholder is preserved
- *
- * This also ensures the props exist, so setValue will properly
- * mutate the props object and not create a new one
- */
+ // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then
+ if (transformKeys.has(prop)) {
const originalValue = value;
- // This needs to be a object with the [prop] so we can discover in transform arrays
value = { [prop]: true };
- if (transformKeys.has(prop)) {
- transformStyles.push(() => {
- value = resolveValue(originalValue, get, {
- inlineVariables,
- inheritedVariables,
- renderGuards: guards,
- calculateProps,
- });
- applyValue(target, prop, value);
- });
- } else {
- delayedStyles.push(() => {
- if (getDeepPath(target, prop) === value) {
- delete target[prop];
- value = resolveValue(originalValue, get, {
- inlineVariables,
- inheritedVariables,
- renderGuards: guards,
- calculateProps,
- });
- applyValue(target, prop, value);
- }
+ transformStyles.push(() => {
+ value = resolveDeclaration(originalValue, prop, get, {
+ inlineVariables,
+ inheritedVariables,
+ renderGuards: guards,
+ calculateProps,
+ computingColor,
});
- }
+ applyValue(declarationTarget, prop, value);
+ });
} else {
- value = resolveValue(value, get, {
+ value = resolveDeclaration(value, prop, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
+ computingColor,
});
}
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
}
}
}
+
+// css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, and behaves as if its value were `unset`
+function resolveDeclaration(
+ descriptor: StyleDescriptor,
+ prop: string | number,
+ get: Getter,
+ options: ResolveValueOptions,
+): unknown {
+ const substitution: Substitution = { failed: false, emptied: false };
+ const value = resolveValue(descriptor, get, { ...options, substitution });
+
+ if (!isInvalidAtComputedValueTime(descriptor, value, substitution)) {
+ return value === undefined && isUnsetDescriptor(descriptor) ? null : value;
+ }
+
+ // css-cascade-5 §7.3.3: `unset` resets the property even where its parser has no reset of its own
+ return (
+ resolveValue(
+ unsetDescriptor(descriptor, prop, options.computingColor),
+ get,
+ options,
+ ) ?? null
+ );
+}
+
+// The compiler writes a CSS-wide keyword on a property the runtime parses as that parser's `unset`
+function isUnsetDescriptor(descriptor: StyleDescriptor): boolean {
+ return isStyleFunction(descriptor) && descriptor[2] === "unset";
+}
+
+// The `unset` the compiler writes for the same property: the inherited colour for `color`, the runtime parser's own reset for a property it parses, and the clearing literal otherwise
+function unsetDescriptor(
+ descriptor: StyleDescriptor,
+ prop: string | number,
+ computingColor: boolean | undefined,
+): StyleDescriptor {
+ if (computingColor) {
+ return inheritedColorLookup();
+ } else if (isStyleFunction(descriptor) && descriptor[1] === prop) {
+ const [marker, name, , ...processing] = descriptor;
+ return [marker, name, "unset", ...processing] as StyleFunction;
+ }
+
+ return "unset";
+}
+
+// A var() with no value and no fallback anywhere in the value, or a value its var()s leave empty
+function isInvalidAtComputedValueTime(
+ descriptor: StyleDescriptor,
+ value: unknown,
+ substitution: Readonly,
+): boolean {
+ if (substitution.failed || substitution.emptied) {
+ return true;
+ } else if (!readsVariable(descriptor)) {
+ return false;
+ }
+
+ return value === undefined || isEmptySubstitution(descriptor, value);
+}
+
+function readsVariable(descriptor: StyleDescriptor): boolean {
+ if (!Array.isArray(descriptor)) {
+ return false;
+ } else if (isStyleFunction(descriptor)) {
+ return descriptor[1] === "var" || readsVariable(descriptor[2]);
+ }
+
+ return descriptor.some(readsVariable);
+}
diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts
index 4a88a98a..b3634118 100644
--- a/src/native/styles/functions/filters.ts
+++ b/src/native/styles/functions/filters.ts
@@ -1,70 +1,44 @@
-import { isStyleDescriptorArray } from "react-native-css/utilities";
+import {
+ isStyleDescriptorArray,
+ readAngle,
+ readNonNegativeAmount,
+ readNonNegativeLength,
+} from "react-native-css/utilities";
+import { resetsToNone } from "../resets-to-none";
import type { StyleFunctionResolver } from "../resolve";
import { shorthandHandler } from "../shorthands/_handler";
-export const blur: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- blur: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
-
-export const brightness: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- brightness: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
-
-export const contrast: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- contrast: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
-
-export const grayscale: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- grayscale: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
+type FilterArgumentReader = (argument: unknown) => unknown;
-export const hueRotate: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- hueRotate: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
+// Filter Effects 1: a filter function takes one argument, so one given several is invalid
+function readFilterArgument(args: unknown): unknown {
+ if (!Array.isArray(args)) {
+ return args;
+ }
-export const invert: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- invert: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
+ return args.length === 1 ? args[0] : undefined;
+}
-export const sepia: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- sepia: (Array.isArray(args) ? args[0] : args) as unknown,
+function filterFunction(
+ name: string,
+ readArgument: FilterArgumentReader,
+): StyleFunctionResolver {
+ return (resolveValue, value) => {
+ const argument = readArgument(readFilterArgument(resolveValue(value[2])));
+ return argument === undefined ? undefined : { [name]: argument };
};
-};
+}
-export const saturate: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- saturate: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
-
-export const opacity: StyleFunctionResolver = (resolveValue, value) => {
- const args = resolveValue(value[2]);
- return {
- opacity: (Array.isArray(args) ? args[0] : args) as unknown,
- };
-};
+export const blur = filterFunction("blur", readNonNegativeLength);
+export const brightness = filterFunction("brightness", readNonNegativeAmount);
+export const contrast = filterFunction("contrast", readNonNegativeAmount);
+export const grayscale = filterFunction("grayscale", readNonNegativeAmount);
+export const hueRotate = filterFunction("hueRotate", readAngle);
+export const invert = filterFunction("invert", readNonNegativeAmount);
+export const sepia = filterFunction("sepia", readNonNegativeAmount);
+export const saturate = filterFunction("saturate", readNonNegativeAmount);
+export const opacity = filterFunction("opacity", readNonNegativeAmount);
const color = ["color", "color"] as const;
const offsetX = ["offsetX", "number"] as const;
@@ -90,12 +64,12 @@ export const dropShadow: StyleFunctionResolver = (
options,
) => {
const args = resolveValue(func[2]);
+ if (!isStyleDescriptorArray(args)) {
+ return undefined;
+ }
- return isStyleDescriptorArray(args)
- ? {
- dropShadow: handler(resolveValue, args, get, options),
- }
- : undefined;
+ const shadow: unknown = handler(resolveValue, args, get, options);
+ return shadow === undefined ? undefined : { dropShadow: shadow };
};
/** CSS variables may expand to multiple filters; native filter arrays must be flat. */
@@ -103,11 +77,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",
- );
+ // A keyword among other filters leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset`
+ return filters.some(resetsToNone) ? undefined : filters;
}
- if (value === undefined || value === "initial") return;
- if (value === "none") return null;
+ if (value === undefined) return;
+ 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..fc5db5e9 100644
--- a/src/native/styles/resolve.ts
+++ b/src/native/styles/resolve.ts
@@ -5,7 +5,11 @@ import type {
StyleDescriptor,
StyleFunction,
} from "react-native-css/compiler";
-import { isStyleDescriptorArray } from "react-native-css/utilities";
+import {
+ isInheritedColorLookup,
+ isStyleDescriptorArray,
+ isStyleFunction,
+} from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
import { type Getter, type VariableContextValue } from "../reactivity";
@@ -54,10 +58,22 @@ 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;
+ /** Where a var() that substitutes nothing is recorded, since a list drops the entry it leaves */
+ substitution?: Substitution;
/** Pass down to perform recursive calculations and avoid circular dependencies */
calculateProps?: typeof calculateProps;
};
+/** What the var()s in the value being resolved left it (css-variables-1 §3.1) */
+export interface Substitution {
+ /** A var() had no value and no fallback, which leaves any value invalid at computed-value time */
+ failed: boolean;
+ /** Every entry of a list substituted nothing, which leaves a property's value invalid and a custom property's empty */
+ emptied: boolean;
+}
+
export function resolveValue(
value: StyleDescriptor,
get: Getter,
@@ -91,15 +107,31 @@ export function resolveValue(
}
if (isStyleDescriptorArray(value)) {
- value = value
- .map((d) => resolveValue(d, get, options))
- .filter((d) => d !== undefined);
+ let substitutedNothing = value.length > 0;
+ const entries: unknown[] = [];
- if (castToArray && !Array.isArray(value)) {
- return [value];
- } else {
- return value;
+ for (const entry of value) {
+ const resolved: unknown = resolveValue(entry, get, options);
+
+ if (isEmptySubstitution(entry, resolved)) {
+ continue;
+ }
+
+ substitutedNothing = false;
+
+ if (resolved !== undefined) {
+ entries.push(resolved);
+ } else if (entry !== undefined) {
+ // A component the property cannot take leaves the whole value invalid, never a shorter one
+ return;
+ }
+ }
+
+ if (substitutedNothing && options.substitution) {
+ options.substitution.emptied = true;
}
+
+ return entries;
}
const name = value[1];
@@ -109,7 +141,7 @@ export function resolveValue(
};
if (name === "var") {
- return varResolver(simpleResolve, value, get, options);
+ return resolveVariable(value, get, options);
} else if (name in functionResolvers) {
const fn = functionResolvers[name as keyof typeof functionResolvers];
@@ -166,3 +198,44 @@ export function resolveValue(
}
}
}
+
+// css-variables-1 §3: a var() whose value or fallback is empty substitutes nothing
+export function isEmptySubstitution(
+ descriptor: StyleDescriptor,
+ value: unknown,
+): boolean {
+ return (
+ isStyleFunction(descriptor) &&
+ descriptor[1] === "var" &&
+ Array.isArray(value) &&
+ value.length === 0
+ );
+}
+
+function resolveVariable(
+ fn: StyleFunction,
+ get: Getter,
+ options: ResolveValueOptions,
+): unknown {
+ if (options.computingColor && isInheritedColorLookup(fn)) {
+ const inheritedScope: ResolveValueOptions = {
+ ...options,
+ inlineVariables: undefined,
+ computingColor: false,
+ };
+
+ return varResolver(
+ (inner) => resolveValue(inner, get, inheritedScope),
+ fn,
+ get,
+ inheritedScope,
+ );
+ }
+
+ return varResolver(
+ (inner) => resolveValue(inner, get, options),
+ fn,
+ get,
+ options,
+ );
+}
diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts
index b8147d1a..47ae8495 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.flat(), ...defaults]);
+ }
+
if (!Array.isArray(args)) {
return;
}
@@ -133,3 +137,19 @@ 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)
+export function clearedLonghands(
+ longhandEntries: readonly ShorthandRequiredValue[],
+) {
+ const target: Record = { [ShortHandSymbol]: true };
+ const longhands = longhandEntries.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/animation.ts b/src/native/styles/shorthands/animation.ts
index f28aacd9..76fccf59 100644
--- a/src/native/styles/shorthands/animation.ts
+++ b/src/native/styles/shorthands/animation.ts
@@ -3,7 +3,7 @@ import { StyleCollection } from "react-native-css/native-internal";
import { applyShorthand } from "../../objects";
import type { StyleFunctionResolver } from "../resolve";
-import { shorthandHandler } from "./_handler";
+import { clearedLonghands, shorthandHandler } from "./_handler";
const name = ["animationName", "string", "none"] as const;
const delay = ["animationDelay", "number", 0] as const;
@@ -33,6 +33,16 @@ const timingFunction = [
["linear", "ease", "ease-in", "ease-out", "ease-in-out", "object"],
"ease",
] as const;
+const longhands = [
+ name,
+ delay,
+ direction,
+ duration,
+ fill,
+ iteration,
+ playState,
+ timingFunction,
+];
export const animationShorthand = shorthandHandler(
[
@@ -48,16 +58,7 @@ export const animationShorthand = shorthandHandler(
[duration, delay, iteration, timingFunction, name],
[name, duration, timingFunction, delay, iteration, fill],
],
- [
- name,
- delay,
- direction,
- duration,
- fill,
- iteration,
- playState,
- timingFunction,
- ],
+ longhands,
"tuples",
);
@@ -75,7 +76,10 @@ export const animation: StyleFunctionResolver = (
);
if (!Array.isArray(animationShortHandTuples)) {
- return;
+ // css-cascade-5 §7.3.3: a shorthand reset to `unset` resets every longhand it sets
+ return resolveValue(value[2]) === null
+ ? clearedLonghands(longhands)
+ : undefined;
}
const nameTuple = animationShortHandTuples.find(
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..ac276f77 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,11 @@ export const transform: StyleFunctionResolver = (
const transforms = resolveValue(transformDescriptor[2]);
if (Array.isArray(transforms)) {
- return transforms.filter(
- (transform) => transform !== undefined && transform !== "initial",
- ) as unknown;
+ // A var() holding several transforms substitutes each of them into the list
+ const entries: unknown[] = transforms.flat();
+
+ // A keyword among other transforms leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset`
+ return entries.some(resetsToNone) ? undefined : entries;
} else if (transforms) {
// If it's a single transform, wrap it in an array
return [transforms];
diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts
index 0b785645..5e730ad3 100644
--- a/src/native/styles/variables.ts
+++ b/src/native/styles/variables.ts
@@ -3,10 +3,36 @@ import {
rootVariables,
universalVariables,
} from "react-native-css/native-internal";
-import { isStyleDescriptorArray } from "react-native-css/utilities";
+import {
+ isGuaranteedInvalidValue,
+ isInheritedValue,
+ isStyleDescriptorArray,
+} from "react-native-css/utilities";
import { VAR_SYMBOL, type Getter } from "../reactivity";
-import type { ResolveValueOptions, SimpleResolveValue } from "./resolve";
+import type {
+ ResolveValueOptions,
+ SimpleResolveValue,
+ Substitution,
+} from "./resolve";
+
+// A variable rncss publishes itself carries a property's computed value, which always exists, so one nothing publishes leaves the property its default
+const LIBRARY_VARIABLE_PREFIX = "__rn-css-";
+
+function isLibraryVariable(name: string): boolean {
+ return name.startsWith(LIBRARY_VARIABLE_PREFIX);
+}
+
+// What one source declares for the variable: nothing, its parent's value, the guaranteed-invalid value, or a value
+type Declaration =
+ | { readonly kind: "absent" }
+ | { readonly kind: "inherit" }
+ | { readonly kind: "guaranteed-invalid" }
+ | { readonly kind: "value"; readonly value: unknown };
+
+const ABSENT: Declaration = { kind: "absent" };
+const INHERIT: Declaration = { kind: "inherit" };
+const GUARANTEED_INVALID: Declaration = { kind: "guaranteed-invalid" };
export function varResolver(
resolve: SimpleResolveValue,
@@ -41,7 +67,20 @@ export function varResolver(
return;
}
- if (variableCycles.has(name)) return resolve(fallback);
+ // css-variables-1 §3.1: a var() with no fallback for a variable that has no value leaves its declaration invalid at computed-value time
+ const substituteFallback = (): unknown => {
+ if (
+ fallback === undefined &&
+ options.substitution &&
+ !isLibraryVariable(name)
+ ) {
+ options.substitution.failed = true;
+ }
+
+ return resolve(fallback);
+ };
+
+ if (variableCycles.has(name)) return substituteFallback();
// Share the active resolution path with recursive calls. Mark only the
// actual cycle, so a dependent variable can still use its own fallback.
@@ -56,43 +95,117 @@ export function varResolver(
variableHistory.add(name);
- try {
- let value = resolve(inlineVariables?.[name] as StyleDescriptor);
- if (variableCycles.has(name)) return resolve(fallback);
+ const memoise = (value: unknown) => {
if (value !== undefined) {
options.inlineVariables ??= { [VAR_SYMBOL]: "inline" };
options.inlineVariables[name] = value;
+ }
- return value;
+ return value;
+ };
+
+ // css-variables-1 §3.1: a value a var() leaves without one, like a member of a cycle, is the guaranteed-invalid value
+ const readDeclaration = (
+ descriptor: StyleDescriptor | undefined,
+ ): Declaration => {
+ if (descriptor === undefined) {
+ return ABSENT;
+ } else if (isGuaranteedInvalidValue(descriptor)) {
+ return GUARANTEED_INVALID;
+ } else if (isInheritedValue(descriptor)) {
+ return INHERIT;
}
- // A universal selector declares the variable on this element. It has lower
- // specificity than the element's class declarations, but beats inheritance.
- value = resolve(get(universalVariables(name)));
- if (variableCycles.has(name)) return resolve(fallback);
- if (value !== undefined) {
- options.inlineVariables ??= { [VAR_SYMBOL]: "inline" };
- options.inlineVariables[name] = value;
- return value;
+ const outer = options.substitution;
+ const substitution: Substitution = { failed: false, emptied: false };
+ options.substitution = substitution;
+
+ try {
+ const value = resolve(descriptor);
+
+ return substitution.failed || variableCycles.has(name)
+ ? GUARANTEED_INVALID
+ : { kind: "value", value };
+ } finally {
+ options.substitution = outer;
}
+ };
- // Guards retain the ancestor's raw descriptor, matching the context value.
- if (name in variables) {
- renderGuards?.push(["v", name, variables[name]]);
- value = resolve(variables[name]);
- return variableCycles.has(name) ? resolve(fallback) : value;
+ // The parent's value: what it hands down, or else what `*` declares on it, then what :root does
+ const readParentValue = (readsUniversal: boolean): unknown => {
+ const handedDown = readOwnEntry(variables, name);
+
+ // Guards retain the ancestor's raw descriptor, matching the context value
+ if (handedDown !== undefined) {
+ renderGuards?.push(["v", name, handedDown]);
+ const inherited = readDeclaration(handedDown);
+
+ if (inherited.kind === "value") {
+ return inherited.value;
+ } else if (inherited.kind === "guaranteed-invalid") {
+ return substituteFallback();
+ }
}
- value = resolve(get(rootVariables(name)));
- if (variableCycles.has(name)) return resolve(fallback);
- if (value !== undefined) {
- options.inlineVariables ??= { [VAR_SYMBOL]: "inline" };
- options.inlineVariables[name] = value;
- return value;
+ if (readsUniversal) {
+ const universal = readDeclaration(get(universalVariables(name)));
+
+ if (universal.kind === "value") {
+ return memoise(universal.value);
+ } else if (universal.kind === "guaranteed-invalid") {
+ return substituteFallback();
+ }
}
- return resolve(fallback);
+ // At the root a keyword resolves to nothing, so the fallback applies
+ const root = readDeclaration(get(rootVariables(name)));
+
+ return root.kind === "value" ? memoise(root.value) : substituteFallback();
+ };
+
+ try {
+ const own = readDeclaration(readOwnEntry(inlineVariables, name));
+
+ switch (own.kind) {
+ case "value":
+ return memoise(own.value);
+ case "guaranteed-invalid":
+ return substituteFallback();
+ case "inherit":
+ // A custom property set to `inherit` takes its parent's value, over every declaration on the element
+ return readParentValue(true);
+ case "absent":
+ break;
+ default:
+ own satisfies never;
+ }
+
+ // A universal selector declares the variable on every element: below a class, above inheritance
+ const universal = readDeclaration(get(universalVariables(name)));
+
+ switch (universal.kind) {
+ case "value":
+ return memoise(universal.value);
+ case "guaranteed-invalid":
+ return substituteFallback();
+ case "inherit":
+ case "absent":
+ return readParentValue(false);
+ default:
+ universal satisfies never;
+ return;
+ }
} finally {
variableHistory.delete(name);
}
}
+
+// A variable's name is its own, never a property every object inherits, and an entry with no value is no declaration
+function readOwnEntry(
+ record: Readonly> | undefined,
+ name: string,
+): StyleDescriptor | undefined {
+ return record !== undefined && Object.hasOwn(record, name)
+ ? (record[name] as StyleDescriptor | undefined)
+ : undefined;
+}
diff --git a/src/utilities/angle.ts b/src/utilities/angle.ts
new file mode 100644
index 00000000..24a53ae8
--- /dev/null
+++ b/src/utilities/angle.ts
@@ -0,0 +1,24 @@
+const ANGLE = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(deg|grad|rad|turn)$/i;
+const DEGREES_PER_ANGLE_UNIT: Readonly> = {
+ deg: 1,
+ grad: 0.9,
+ turn: 360,
+};
+
+// React Native takes an angle in degrees or radians alone, so a gradian or a turn arrives in degrees
+export function readAngle(argument: unknown): number | string | undefined {
+ if (argument === 0) {
+ return 0;
+ }
+
+ const match = typeof argument === "string" ? ANGLE.exec(argument) : null;
+ if (!match) {
+ return undefined;
+ }
+
+ const [, amount = "", unit = ""] = match;
+ const degreesPerUnit = DEGREES_PER_ANGLE_UNIT[unit.toLowerCase()];
+ return degreesPerUnit === undefined
+ ? `${amount}rad`
+ : `${Number(amount) * degreesPerUnit}deg`;
+}
diff --git a/src/utilities/custom-property-keywords.ts b/src/utilities/custom-property-keywords.ts
new file mode 100644
index 00000000..da6cdb0f
--- /dev/null
+++ b/src/utilities/custom-property-keywords.ts
@@ -0,0 +1,31 @@
+import type { StyleFunction } from "react-native-css/compiler";
+
+import { isStyleFunction } from "./style-descriptor";
+
+// css-variables-1 §2: a custom property set to a CSS-wide keyword takes the keyword's value itself, which `var()` then substitutes
+const INHERITED_VALUE = "inherit";
+const GUARANTEED_INVALID_VALUE = "initial";
+
+/** The value of a custom property set to `inherit`, `unset`, `revert` or `revert-layer`: its parent's */
+export function inheritedValue(): StyleFunction {
+ return [{}, INHERITED_VALUE];
+}
+
+/** The value of a custom property set to `initial`: the guaranteed-invalid value, which no `var()` substitutes */
+export function guaranteedInvalidValue(): StyleFunction {
+ return [{}, GUARANTEED_INVALID_VALUE];
+}
+
+export function isInheritedValue(value: unknown): boolean {
+ return (
+ isStyleFunction(value) && value.length === 2 && value[1] === INHERITED_VALUE
+ );
+}
+
+export function isGuaranteedInvalidValue(value: unknown): boolean {
+ return (
+ isStyleFunction(value) &&
+ value.length === 2 &&
+ value[1] === GUARANTEED_INVALID_VALUE
+ );
+}
diff --git a/src/utilities/filter-arguments.ts b/src/utilities/filter-arguments.ts
new file mode 100644
index 00000000..f9aee3f6
--- /dev/null
+++ b/src/utilities/filter-arguments.ts
@@ -0,0 +1,18 @@
+const NON_NEGATIVE_PERCENTAGE = /^\+?(?:\d*\.)?\d+(?:e[+-]?\d+)?%$/i;
+
+// Filter Effects 1: blur takes a non-negative length, hue-rotate an angle or unitless zero, and every other function a non-negative number or percentage
+export function readNonNegativeLength(argument: unknown): number | undefined {
+ return typeof argument === "number" && argument >= 0 ? argument : undefined;
+}
+
+export function readNonNegativeAmount(
+ argument: unknown,
+): number | string | undefined {
+ if (typeof argument === "number") {
+ return argument >= 0 ? argument : undefined;
+ }
+
+ return typeof argument === "string" && NON_NEGATIVE_PERCENTAGE.test(argument)
+ ? argument
+ : undefined;
+}
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
index 0c95da63..cbabf574 100644
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -1,3 +1,7 @@
export * from "./specificity";
export * from "./style-descriptor";
export * from "./dot-notation.types";
+export * from "./inherited-color";
+export * from "./custom-property-keywords";
+export * from "./angle";
+export * from "./filter-arguments";
diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts
new file mode 100644
index 00000000..6aac8efe
--- /dev/null
+++ b/src/utilities/inherited-color.ts
@@ -0,0 +1,84 @@
+import type {
+ StyleDescriptor,
+ StyleFunction,
+ StyleRule,
+} from "react-native-css/compiler";
+
+import { isInheritedValue } from "./custom-property-keywords";
+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";
+
+// A fresh tuple per call, because a descriptor is owned by the rule it lands in
+export function inheritedColorLookup() {
+ return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction;
+}
+
+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 to the element's own, and returns the inherited colour it publishes for `assignPublishedColor` */
+export function assignRuleVariables(
+ variables: Record,
+ entries: NonNullable,
+): StyleDescriptor | undefined {
+ return assignEntries(entries, (name, value) => {
+ variables[name] = value;
+ });
+}
+
+/** Assigns a rule's custom properties to what its subtree inherits, where one set to `inherit` hands down the parent's value */
+export function assignSubtreeVariables(
+ variables: Record,
+ entries: NonNullable,
+ parentVariables: Readonly>,
+): StyleDescriptor | undefined {
+ return assignEntries(entries, (name, value) => {
+ if (!isInheritedValue(value)) {
+ variables[name] = value;
+ } else {
+ variables[name] = Object.hasOwn(parentVariables, name)
+ ? parentVariables[name]
+ : undefined;
+ }
+ });
+}
+
+function assignEntries(
+ entries: NonNullable,
+ assign: (name: string, value: StyleDescriptor) => void,
+): StyleDescriptor | undefined {
+ let color: StyleDescriptor | undefined;
+
+ for (const [name, value] of entries) {
+ if (name === INHERITED_COLOR_VARIABLE) {
+ color = value;
+ } else {
+ assign(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;
+ }
+}