diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 7a9fbea8..84ac48a5 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(` @@ -448,3 +452,492 @@ test("simplifies rem", () => { ], }); }); + +describe("CSS-wide color keywords", () => { + const stylesheetFor = (value: string) => + compile(`.child { color: ${value}; }`).stylesheet(); + + test("compiles to the inherited-color variable instead of being dropped", () => { + // lightningcss emits `color: inherit` as an UnparsedProperty — the keyword + // is not a CssColor — so it lands in parseUnparsed's ident branch, which + // drops every keyword it has no resolution context for. Per CSS Color, + // `currentcolor` used as the value of `color` is defined as `inherit`, so + // both spell the same computed value and resolve to the same variable. + // The ABSENCE of a `v` entry is the no-self-reference guarantee: publishing + // this value as its own --__rn-css-color seeds a cycle a descendant then + // recurses into (see "never publishes a self-referential" below). + expect(stylesheetFor("inherit")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("inherit and currentcolor compile identically (CSS Color spec identity)", () => { + // The two keywords travel different code paths — `inherit` through + // parseUnparsed's ident branch, `currentcolor` through parseColor — and + // must converge on the same output. + expect(stylesheetFor("inherit")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("PIN: currentcolor resolves to the inherited-color variable", () => { + // A pin of behaviour that predates this change, not a guard for it: + // `color: currentcolor` never reaches the ident branch below. lightningcss + // parses it as a CssColor, so it is `parseColor`'s `case "currentcolor"` + // that produces this lookup and `parseFontColorDeclaration`'s own + // `type !== "currentcolor"` check that withholds the `v`. + expect(stylesheetFor("currentcolor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("PIN: a normal color publishes --__rn-css-color to descendants", () => { + // A pin of behaviour that predates this change: `color: red` is a CssColor, + // so it is `parseFontColorDeclaration` that publishes the `v`. It is here + // because the guard added for the keywords must not swallow this case. + expect(stylesheetFor("red")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [{ color: "#f00" }], + v: [["__rn-css-color", "#f00"]], + }, + ], + ], + ], + }); + }); + + test("inherit on a non-color property is still dropped (no inheritance context)", () => { + expect( + compile(`.child { font-size: inherit; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("border-color: inherit is still dropped", () => { + // The near miss to the `property === "color"` gate: border-color IS a colour + // property, but it is not `color`, so it publishes nothing and inherits + // nothing. Only `color` seeds --__rn-css-color, so only `color` can read it + // back as `inherit`. + expect( + compile(`.child { border-color: inherit; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("color: initial is still dropped (different semantics, out of scope)", () => { + expect(stylesheetFor("initial")).toStrictEqual({}); + }); + + test.each([ + ["background-color", "inherit"], + ["background-color", "initial"], + ["background-color", "revert"], + ["background-color", "revert-layer"], + ["border-color", "revert"], + ["font-size", "revert"], + ])("%s: %s is dropped on a non-color property too", (property, keyword) => { + // The drop arm is keyword-first, not property-first: only the RESOLVING + // arm is gated on `property === "color"`. `revert` and `revert-layer` + // previously fell through to the style as their literal string on every + // property, so this pins the widened drop across the property axis rather + // than on `color` alone. + expect( + compile(`.child { ${property}: ${keyword}; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("unset on a non-color property is NOT dropped", () => { + // The exception to the arm above, and the reason `unset` is absent from it. + // On a non-inherited property `unset` means `initial`, and the literal left + // here is what the runtime clears the declared colour with — see + // `background-color: unset still clears the color` in + // src/__tests__/native/colors.test.tsx. Dropping it would take that away, + // and nothing else in the compiler would notice. + expect( + compile(`.child { background-color: unset; }`).stylesheet(), + ).toStrictEqual({ + s: [["child", [{ s: [1, 1], d: [["unset", "backgroundColor"]] }]]], + }); + }); + + /** + * The custom-property rows of the keyword table above, which cannot share its + * one-declaration template — see `uninlinedCustomProperty`. + * + * A custom property's value is a raw token stream with no property to inherit + * FROM and no per-property initial value to fall back to, so + * `property === "color"` is false and the resolving arm never fires for one. + * `currentcolor` is the exception, and it is not an exception to the gate: + * that arm is keyword-only, because `currentcolor` is valid on every + * property, custom ones included. + */ + const uninlinedCustomProperty = (value: string) => + // Two definitions, deliberately. react-native-css's own `inlineVariables` + // pass keys on a custom property's DECLARATION COUNT: a name declared once + // is folded into its consumer at compile time and the declaration is + // deleted, so a single-definition case never reaches the keyword arm as a + // custom property at all. The second definition is what puts it there — + // and it is why the `currentcolor` rows below expect TWO published values, + // one per declaring rule. + `.child { --brand: ${value}; color: var(--brand); } + .other { --brand: ${value}; }`; + + test.each(["inherit", "initial", "revert", "revert-layer", "INHERIT"])( + "--brand: %s is dropped — a custom property has no property context", + (keyword) => { + expect( + publishedVariable(uninlinedCustomProperty(keyword), "brand"), + ).toEqual([]); + }, + ); + + test.each(["currentcolor", "currentColor"])( + "--brand: %s resolves — the currentcolor arm is keyword-only, not property-gated", + (spelling) => { + // Also the vacuity guard for the drop rows above: same construction, so + // an empty result here would mean the inliner had eaten the declaration + // and the `[]` there was proving nothing. + // + // The camelCase spelling is the one THIS package folds — lightningcss + // hands a custom property's tokens through verbatim, so without the + // `.toLowerCase()` in parseUnparsed the literal string "currentColor" is + // published as the variable's value and every consumer renders that. + expect( + publishedVariable(uninlinedCustomProperty(spelling), "brand"), + ).toEqual([ + [{}, "var", "__rn-css-color"], + [{}, "var", "__rn-css-color"], + ]); + }, + ); + + test("--brand: unset keeps its literal, like unset on any non-color property", () => { + // `unset` is absent from the drop rows for the same reason it is absent + // from the keyword table: on anything that is not `color` it means + // `initial`, and the literal is what the runtime clears a value with. + expect( + publishedVariable(uninlinedCustomProperty("unset"), "brand"), + ).toEqual(["unset", "unset"]); + }); + + test("a custom property declared ONCE is folded into its consumer first", () => { + // Why the rows above declare `--brand` twice. With a single definition the + // inliner substitutes the value and deletes the declaration, so + // `color: var(--brand)` becomes `color: inherit` and takes the resolving + // arm — the opposite outcome from the identical CSS carrying one more + // definition of the same name. + expect( + compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("color: unset resolves like inherit (unset on an inherited property is inherit)", () => { + // Per CSS Cascade, `unset` computes to `inherit` on inherited properties, + // and `color` is inherited — so it maps to the same inherited-color variable. + expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit")); + }); + + test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])( + "keyword matching is case-insensitive (%s)", + (spelling) => { + // CSS-wide keywords are case-insensitive; lightningcss does not fold case, + // so the ident branch has to. INITIAL is in the census because the fold + // must reach the drop-with-a-warning arm too, not only the resolving one. + expect(stylesheetFor(spelling)).toStrictEqual( + stylesheetFor(spelling.toLowerCase()), + ); + }, + ); + + test("PIN: currentColor (camelCase) resolves to the inherited-color variable", () => { + // A pin of behaviour that predates this change. On the PARSED-color path + // the case fold is lightningcss's, not ours — it parses either spelling + // into the same CssColor before this package sees it. + // + // Asserted against the output rather than against + // `stylesheetFor("currentcolor")`. An equality between two spellings + // lightningcss has ALREADY folded holds whatever this package then does + // with the result, so it cannot fail: break `parseColor`'s currentcolor + // case and both sides move together while the sibling pin above goes red. + // The spelling this package folds itself is the one that reaches the ident + // branch — pinned by `--brand: currentColor` in the custom-property rows + // above, where the fold is ours and is new here. + expect(stylesheetFor("currentColor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("PIN: currentcolor resolves on a non-color property too (border-color)", () => { + // A pin of behaviour that predates this change: border-color is a parsed + // CssColor, so this is parseColor's `case "currentcolor"` again. The ident + // branch's own currentcolor clause is what serves the UNPARSED properties — + // box-shadow, filter: drop-shadow(), and custom properties — and those are + // covered by src/__tests__/native/{box-shadow,filters}.test.tsx. + 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, + }, + ], + ], + ], + }); + }); + + 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, + }, + ], + ], + ], + }); + }); + + 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, + p: { h: 1 }, + }, + ], + ], + ], + }); + }); + + test.each([ + ["placeholder", "placeholderTextColor"], + ["selection", "selectionColor"], + ])("color: inherit on ::%s targets %s", (pseudoElement, targetProp) => { + // A pseudo-element rule retargets the declaration off `style`, so the + // inherited-color lookup has to survive the retarget. + expect( + declarationsFor(`.child::${pseudoElement} { color: inherit; }`), + ).toStrictEqual([[[{}, "var", "__rn-css-color"], [targetProp], 1]]); + }); + + test.each(["revert", "revert-layer"])( + "color: %s is dropped rather than published as a literal", + (keyword) => { + // React Native has no cascade origins to revert to, so the keyword has no + // computed value here. Emitting the literal string put `color: "revert"` + // in the style AND published it as --__rn-css-color, handing every + // descendant that reads the inherited color an unusable value. + expect(stylesheetFor(keyword)).toStrictEqual({}); + }, + ); +}); + +/** Every declaration every rule in `css` produces, in compile order. */ +function declarationsFor(css: string): StyleDeclaration[] { + return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) => + ruleSet.flatMap((rule) => rule.d ?? []), + ); +} + +/** + * Every value any rule in `css` publishes as the custom property `name`, in + * compile order and once per publishing rule. + * + * Derived from the compiled output rather than restated, so a new rule shape + * that publishes the variable is covered without editing the reader. + */ +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), + ), + ); +} + +/** Every value any rule in `css` publishes as `--__rn-css-color`. */ +function publishedInheritedColors(css: string): StyleDescriptor[] { + return publishedVariable(css, "__rn-css-color"); +} + +describe("the inherited-color variable is never self-referential", () => { + /** + * Each of these makes `color` READ --__rn-css-color from somewhere below the + * top level of the descriptor, which is what a guard comparing only the top + * level misses. Publishing any of them as --__rn-css-color hands a descendant + * a value that resolves back into the same variable, and resolution recurses + * until the stack is exhausted. + */ + const selfReferentialColors = [ + "inherit", + "unset", + "currentcolor", + "var(--missing, inherit)", + "var(--missing, unset)", + "var(--missing, currentcolor)", + "color-mix(in srgb, currentcolor, blue)", + "color-mix(in srgb, inherit, blue)", + "rgb(from currentcolor r g b)", + "light-dark(currentcolor, blue)", + ]; + + test("the census is not empty", () => { + expect(selfReferentialColors.length).toBeGreaterThan(0); + }); + + test.each(selfReferentialColors)("color: %s publishes no `v`", (value) => { + expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([]); + }); + + 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, + ]); + }); + + /** + * The discriminating half of the walk. Every one of these CONTAINS a `var()` + * — bare, with a fallback, and nested inside a function's argument list — but + * none of them names the inherited color, so every one must still publish. + * + * The census above cannot see this: each of its values resolves to a plain + * string, so a walk that answered "reads the inherited color" for ANY `var()` + * would leave it green. That mistake does not crash — it silently withholds + * the publish, and every descendant of a `color: var(--brand)` rule stops + * inheriting. These are the values that tell the two apart. + */ + 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], + ], + ], + ], + // light-dark() publishes from its own rule AND from the extra + // `prefers-color-scheme: dark` rule it pushes, so the census sees two. + ["light-dark(red, blue)", ["#f00", "#f00"]], + ])( + "color: %s names a variable that is not the inherited one, so it publishes", + (value, published) => { + expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual( + published, + ); + }, + ); + + test("light-dark() on color emits one dark rule, not one per parse", () => { + // `light-dark()` pushes an extra `prefers-color-scheme: dark` rule as a + // SIDE EFFECT of parsing, so the colour must be parsed exactly once for the + // declaration and the published variable both. + const darkRules = ( + compile(`.child { color: light-dark(red, blue); }`).stylesheet().s ?? [] + ) + .flatMap(([, ruleSet]) => ruleSet) + .filter((rule) => rule.m !== undefined); + + expect(darkRules).toHaveLength(1); + }); +}); diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 6a8c7255..849f54e8 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,6 +1,8 @@ -import { render, screen } from "@testing-library/react-native"; +import { act, 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"; +import { colorScheme } from "react-native-css/runtime"; describe("hsl", () => { test("inline", () => { @@ -165,3 +167,653 @@ 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("text-inherit: a child Text inherits its parent's color", () => { + // The shape that surfaced the bug: a labelled button whose label renders + // React Native's default color (black) on native instead of the button's + // foreground color, while web inherits correctly. + registerCSS(` + .button { color: white; } + .label { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("label").props.style).toStrictEqual({ + color: "#fff", + }); + }); + + test("inherit chains through an inheriting ancestor without breaking the chain", () => { + // The middle node inherits and must NOT republish a circular + // --__rn-css-color, or the grandchild would fail to resolve the color. + 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, same as inherit", () => { + // `unset` computes to `inherit` on inherited properties, and color is one. + 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 is case-folded into the drop, not into the lookup", () => { + registerCSS(` + .parent { color: red; } + .child { color: INITIAL; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toBeUndefined(); + }); + + 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 and ::selection", () => { + registerCSS(` + .parent { color: red; } + .child::placeholder { color: inherit; } + .child::selection { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#f00", + selectionColor: "#f00", + style: {}, + testID: "child", + }); + }); + + test.each(["border-color", "background-color"])( + "%s: inherit is dropped, it does not read the color variable", + (property) => { + // Only `color` seeds --__rn-css-color, so only `color` can read it back. + // Neither of these inherits in CSS either, so there is nothing for them + // to have inherited even if a per-property context existed. + registerCSS(` + .parent { color: red; } + .child { ${property}: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toBeUndefined(); + }, + ); + + test("background-color: unset still clears the color", () => { + // The counterpart to the drop above. `unset` on a non-inherited property + // means `initial`, and the literal the compiler leaves in place is what the + // runtime clears the declared colour with — so adding `unset` to the + // keyword drop would silently take away the only way to clear one. The + // cleared element keeps the KEY and loses the value, which is how a later + // rule overrides an earlier one here rather than merging with it. + registerCSS(` + .filled { background-color: red; } + .cleared { background-color: unset; } + `); + + render( + <> + + + , + ); + + expect(screen.getByTestId("filled").props.style).toStrictEqual({ + backgroundColor: "#f00", + }); + expect(screen.getByTestId("cleared").props.style).toStrictEqual({ + backgroundColor: undefined, + }); + }); + + test("color: inherit with no colored ancestor falls back to the root seed", () => { + // Nothing publishes --__rn-css-color above this element, so the read lands + // on the value the root seeds it with: the platform's label colour. The + // failure this guards is not a wrong colour but an UNRESOLVED one — the + // pre-fix drop left `style` undefined and React Native painted its own + // default, and a read that resolved to nothing would do the same. + registerCSS(`.child { color: inherit; }`); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: { semantic: ["label", "labelColor"] }, + }); + }); + + test("inherit resolves an ancestor color that is itself a variable", () => { + // `--brand` has a single definition, so the compiler inlines it and the + // published inherited colour is already a resolved string. + 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 an UNINLINED variable", () => { + // A second definition of `--brand` stops the compiler inlining it, so the + // ancestor publishes the var() lookup itself rather than a resolved colour. + // The descendant must still end up with the ancestor's COMPUTED colour — + // which is what `readsInheritedColor` letting a non-inherited `var()` + // through is for. Asserted as an equality against the ancestor rather than + // a literal: the class is that the two agree, and the raw-token colour a + // named-colour custom property currently produces is not this fix's to pin. + 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, + }); + }); + + /** + * `color: var(--brand)` where the KEYWORD is the custom property's value. + * + * The two tests below are the same CSS but for one extra declaration of + * `--brand`, and they end at opposite outcomes, because `inlineVariables` + * keys on a custom property's DECLARATION COUNT: + * + * - declared once, the value is folded into its consumer at compile time and + * the rule compiles as `color: ` — the property context exists and + * `inherit` resolves; + * - declared twice or more, the fold is defeated, `var(--brand)` survives as + * a runtime lookup, and the compiler meets the keyword on a CUSTOM property + * instead, where there is no property to inherit from — so it drops and the + * lookup resolves to nothing. + * + * Mapping `color: inherit` to the inherited-color variable reaches the folded + * route only: before it BOTH routes were broken, so pinning them together is + * what records that the split between them is new. + */ + 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("color: var(--brand) with --brand: inherit drops when the variable is NOT inlined", () => { + // The unfolded half of the pair, pinned at the current output rather than + // at the CSS-correct one. Per CSS the child computes to red here too. The + // keyword is not the only thing that would have to change to get there: a + // custom property would need to carry the property context of whatever + // consumes it, which is a resolver change, not a keyword-table one. + registerCSS(` + .parent { color: red; } + .child { --brand: inherit; color: var(--brand); } + .other { --brand: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({}); + }); + + 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) => { + // The control for the pair above: `currentcolor` is resolved by a + // keyword-only arm, so it never needs a property context and is symmetric + // across the fold. The camelCase spelling is symmetric too only because + // parseUnparsed folds case: lightningcss hands a custom property's tokens + // through verbatim, so without that fold the uninlined route publishes + // the literal string "currentColor" as the variable's value and this + // element renders it as a colour. + 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", () => { + // The delayed-value placeholder `{ color: true }` is internal bookkeeping. + // A rule whose LAST declaration walks into a nested target (a shadow object) + // must not strand the placeholder of an earlier delayed declaration. + 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", () => { + // The same runtime defect with no `inherit` anywhere in the input. The + // stranded target is a property of how a rule's declarations are walked, + // not of the keyword that made the colour delayed — so this is the pin that + // survives if the calculate-props fix is split into its own change. + 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(["revert", "revert-layer"])( + "color: %s publishes nothing to descendants", + (keyword) => { + // React Native has no cascade origins, so neither keyword has a computed + // value. Emitting the literal handed every descendant `color: "revert"`. + registerCSS(` + .parent { color: red; } + .mid { color: ${keyword}; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("mid").props.style).toBeUndefined(); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }, + ); + + test("a light-dark() ancestor 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", + }); + + act(() => { + colorScheme.set("dark"); + }); + + // KNOWN DIVERGENCE, pinned at the current output rather than at the + // CSS-correct one — the same treatment the `rgb(from …)` census entry below + // gets. Per CSS the descendant computes to the ancestor's used colour, so + // both should be `#00f` here. `light-dark()` instead publishes + // --__rn-css-color from its LIGHT branch only: the extra + // `prefers-color-scheme: dark` rule carries the dark `color` declaration + // beside the light published value. + // + // It predates this change — it reproduces with the double parse restored — + // and it is #420's defect 2, so it is pinned here rather than fixed. Which + // of the two lands first decides who updates this expectation. + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#00f", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); + +/** + * Each of these makes the middle element's `color` READ the inherited-color + * variable from below the top level of its descriptor. Publishing such a value + * as --__rn-css-color hands the child a value that resolves back into the same + * variable, and resolution recurses until the stack is exhausted. + * + * The middle element resolves against ITS parent, so the child sees the nearest + * ancestor that published a colour of its own — the red parent. + */ +const selfReferentialMiddleColors: [css: string, midColor: string][] = [ + ["inherit", "#f00"], + ["unset", "#f00"], + ["currentcolor", "#f00"], + ["var(--missing, inherit)", "#f00"], + ["var(--missing, unset)", "#f00"], + ["var(--missing, currentcolor)", "#f00"], + ["color-mix(in srgb, currentcolor, blue)", "rgba(127.5, 0, 127.5, 1)"], + ["color-mix(in srgb, inherit, blue)", "rgba(127.5, 0, 127.5, 1)"], + ["light-dark(currentcolor, blue)", "#f00"], + // Relative colour syntax is not implemented, so the mid colour is the + // stringified function rather than a colour. It is here for the crash, and it + // pins the current output so that implementing `rgb(from …)` has to update it. + ["rgb(from currentcolor r g b)", "rgb(from, #f00, r, g, b)"], +]; + +describe("a color that reads the inherited color never publishes itself", () => { + test("the census is not empty", () => { + expect(selfReferentialMiddleColors.length).toBeGreaterThan(0); + }); + + test.each(selfReferentialMiddleColors)( + "mid { color: %s } renders, and its child inherits the grandparent's color", + (midColorValue, expectedMidColor) => { + registerCSS(` + .parent { color: red; } + .mid { color: ${midColorValue}; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("mid").props.style).toStrictEqual({ + color: expectedMidColor, + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }, + ); +}); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index b7a08fca..cf85f25f 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -154,3 +154,55 @@ describe("transform", () => { }); }); }); + +describe("a transform's target survives a later nested declaration", () => { + // A transform key resolves through a closure that runs after every + // declaration in the rule has been walked. A later declaration that walks + // into a NESTED target — a box-shadow entry is the one that reaches here — + // must not move the object those closures write into. + test("translate before a box-shadow still 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", + }, + ], + }); + }); +}); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..c286a3ca 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -322,9 +322,17 @@ describe("Typography - Text Color", () => { }); }); test("text-inherit", async () => { + // Per CSS Color, `inherit` on the `color` property is defined as + // `currentcolor`, so text-inherit resolves to the platform label color — + // identical to text-current above — instead of being dropped with a warning. expect(await renderCurrentTest()).toStrictEqual({ - props: {}, - warnings: { values: { color: "inherit" } }, + props: { + style: { + color: { + semantic: ["label", "labelColor"], + }, + }, + }, }); }); }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..1a2d1a9a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -950,15 +950,80 @@ 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); + } + } +} + +/** The variable a color rule publishes and `color: inherit` reads back. */ +const INHERITED_COLOR_VARIABLE = "__rn-css-color"; + +/** + * A read of the inherited color, as `currentcolor` and `color: inherit` both + * compile to it. A fresh tuple per call, because a descriptor is owned by the + * rule it lands in. + */ +function inheritedColorLookup() { + return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction; +} + +/** + * Publish `value` to descendants as the inherited color, unless it reads the + * inherited color itself. + * + * A rule is handed to descendants as an UNRESOLVED descriptor, so a value that + * reads `--__rn-css-color` and is published under that same name resolves back + * into itself: the descendant recurses until the stack is exhausted. Withholding + * the publish leaves the nearest ancestor that names a color of its own as the + * one descendants inherit — which is exactly right for `inherit`, `unset` and + * `currentcolor`, and an approximation for a value that DERIVES from the + * inherited color (`color-mix(in srgb, currentcolor, blue)`), where descendants + * see the ancestor's color rather than the derived one. Publishing the derived + * value is only possible once resolution happens in the publisher's own scope. + */ +function publishInheritedColor( + value: StyleDescriptor, + builder: StylesheetBuilder, +) { + if (readsInheritedColor(value)) { + return; + } + + builder.addDescriptor(`--${INHERITED_COLOR_VARIABLE}`, value); +} + +/** + * Whether `value` reads `var(--__rn-css-color)` anywhere inside it. + * + * The read is not always at the top level. `var(--brand, inherit)` buries it in + * a fallback, `color-mix(in srgb, currentcolor, blue)` and + * `rgb(from currentcolor r g b)` bury it in an argument list, and + * `light-dark(currentcolor, blue)` returns it from a branch — so the whole + * descriptor tree is walked rather than its first level. + */ +function readsInheritedColor(value: StyleDescriptor): boolean { + if (!Array.isArray(value)) { + return false; + } + + if (isStyleFunction(value)) { + const args = value[2]; + + if (value[1] === "var") { + // `var()`'s arguments are the name alone, or `[name, fallback]`. + const name = Array.isArray(args) ? args[0] : args; + + if (name === INHERITED_COLOR_VARIABLE) { + return true; } } + + // A style function's other slots are its marker object, its name and the + // delayed-resolution flag; only the arguments can nest a descriptor. + return readsInheritedColor(args); } + + return value.some((entry) => readsInheritedColor(entry)); } export function parseCustomDeclaration( @@ -1127,7 +1192,7 @@ export function parseUnparsed( } else if (tokenOrValue === "false") { return false; } else if (tokenOrValue === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; + return inheritedColorLookup(); } else { return tokenOrValue; } @@ -1263,11 +1328,46 @@ export function parseUnparsed( return; } - if (value === "inherit" || value === "initial") { + // CSS-wide keywords and `currentcolor` are case-insensitive, and + // lightningcss hands them through unfolded. + const keyword = value.toLowerCase(); + + // Per CSS Color, `currentcolor` as the value of `color` is defined as + // `inherit`; and per CSS Cascade, `unset` on an inherited property + // (`color` is inherited) computes to `inherit` too. So `currentcolor` + // (valid on any property) and `inherit` / `unset` on `color` all + // resolve to the inherited-color variable every color rule publishes + // to its subtree (see publishInheritedColor). + // + // `color: currentcolor` does not arrive here — lightningcss parses it + // into a CssColor, so parseColor handles it. This clause serves the + // UNPARSED properties: box-shadow, filter: drop-shadow(), and custom + // properties, whose values reach the compiler as raw tokens. + if ( + keyword === "currentcolor" || + ((keyword === "inherit" || keyword === "unset") && + property === "color") + ) { + return inheritedColorLookup(); + } + + // `inherit` on any other property has no per-property inheritance + // context here, `initial` has no per-property initial value, and + // React Native has no cascade origins for `revert` / `revert-layer` + // to roll back to. None of them has a value to compile to, so they + // drop with a warning rather than reaching the style as a literal. + // + // `unset` on a non-color property is the exception: it means + // `initial` there, and the runtime already turns the literal into + // `null`, which is how `background-color: unset` clears a color. + if ( + keyword === "inherit" || + keyword === "initial" || + keyword === "revert" || + keyword === "revert-layer" + ) { builder.addWarning("value", value); return; - } else if (value === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; } if (value === "true") { @@ -1589,17 +1689,13 @@ export function parseFontColorDeclaration( declaration: Extract, builder: StylesheetBuilder, ) { - parseColorDeclaration(declaration, builder); + // Parsed once, for the declaration and the published variable both: + // `light-dark()` pushes an extra `prefers-color-scheme: dark` rule as a side + // effect, so a second parse emits a second copy of that rule. + 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( @@ -1638,7 +1734,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: [], diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..e48f4075 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -105,14 +105,20 @@ export function applyDeclarations( target: Record = {}, topLevelTarget = target, ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + /** + * Scoped to THIS declaration. The delayed and transform closures below + * capture it, and they run after every declaration has been walked — so a + * binding shared across iterations hands them whatever nested object the + * LAST declaration ended on (a shadow, a transform entry) instead of the + * target this declaration resolved. The placeholder then never matches, and + * `{ [prop]: true }` is left in the style. + */ + 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]; @@ -131,7 +137,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,19 +149,19 @@ export function applyDeclarations( if (!Array.isArray(previousTarget[previousProp])) { previousTarget[previousProp] = []; - target = previousTarget[previousProp]; + declarationTarget = previousTarget[previousProp]; } } - previousTarget = target; + previousTarget = declarationTarget; previousProp = prop; - target[prop] ??= {}; - target = target[prop]; + declarationTarget[prop] ??= {}; + declarationTarget = declarationTarget[prop]; } prop = final; } else { - target = topLevelTarget; + declarationTarget = topLevelTarget; prop = first; } } else { @@ -186,19 +192,19 @@ export function applyDeclarations( renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); }); } else { delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; + if (getDeepPath(declarationTarget, prop) === value) { + delete declarationTarget[prop]; value = resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } }); } @@ -211,7 +217,7 @@ export function applyDeclarations( }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } }