From f4fd60e16b30aa55d2eecb776242cbef5629775d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 12 Aug 2026 23:58:21 +0300 Subject: [PATCH 1/6] fix(compiler): scope pseudo-element declarations to the pseudo-element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `::selection` / `::placeholder` rule maps ONE declaration onto a React Native prop and returned every other declaration unchanged — so an unmapped one was applied to the real element. `::selection { background-color: blue }` tinted the whole control rather than the selection, silently. The leak was on the mapping path too: `::selection { color: red; background-color: blue }` emitted the blue background AND the mapped prop. Three changes: - Unmapped declarations are DROPPED. `[]` is the correct answer for a declaration the platform cannot express — applying it to the element instead is strictly worse than not applying it. - `::selection` maps `background-color`, not `color`. `selectionColor` is the band painted BEHIND the selected text, which is `background-color` in CSS; `color` there is the selected TEXT's colour, which React Native has no prop for. The old mapping inverted the meaning — a stylesheet asking for white selected text got a white band and unchanged text sitting on it. - `::placeholder` keeps `color` -> `placeholderTextColor`, which is correct, and drops the rest. The second and third are BREAKING for anyone relying on 3.0.7's inverted `color` mapping. `vendor/tailwind/states.test.tsx`'s `selection` case pinned it and is updated to `selection:bg-black`, with a second case asserting that `selection:text-black` no longer reaches the element. 7 new compiler tests, including the control an over-broad fix would break: a plain `.a { background-color }` on the same class is untouched. --- .../compiler/pseudo-elements.test.ts | 73 +++++++++++++++++++ src/__tests__/vendor/tailwind/states.test.tsx | 18 ++++- src/compiler/pseudo-elements.ts | 37 ++++++++-- 3 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/compiler/pseudo-elements.test.ts diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts new file mode 100644 index 00000000..738950bf --- /dev/null +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -0,0 +1,73 @@ +import { compile } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; + +const rulesFor = (css: string, className: string): StyleRule[] => { + const result = compile(css).stylesheet(); + return (result.s?.find(([name]) => name === className)?.[1] ?? + []) as StyleRule[]; +}; + +/** + * A pseudo-element's declarations are scoped to the pseudo-element. The + * compiler maps ONE declaration onto a React Native prop; every other one must + * be DROPPED, not returned unchanged — returning it applies a `::selection` + * declaration to the real element. + */ +describe("::selection", () => { + test("background-color maps to selectionColor", () => { + const rules = rulesFor(`.a::selection { background-color: #ff0000; }`, "a"); + + expect(rules[0]?.d).toStrictEqual([["#f00", ["selectionColor"]]]); + }); + + test("an unmapped declaration is dropped, not applied to the element", () => { + const rules = rulesFor(`.a::selection { width: 10px; }`, "a"); + + expect(rules[0]?.d ?? []).toStrictEqual([]); + }); + + test("an unmapped declaration beside a mapped one is still dropped", () => { + const rules = rulesFor( + `.a::selection { background-color: #ff0000; width: 10px; }`, + "a", + ); + + expect(rules[0]?.d).toStrictEqual([["#f00", ["selectionColor"]]]); + }); + + test("color does not paint the element", () => { + // `color` inside ::selection is the selected TEXT colour, which React + // Native cannot express — mapping it to `selectionColor` would invert its + // meaning, so it is dropped rather than re-targeted. + const rules = rulesFor(`.a::selection { color: #ff0000; }`, "a"); + + expect(rules[0]?.d ?? []).toStrictEqual([]); + }); + + test("a plain rule on the same class is untouched", () => { + // The control an over-broad fix breaks: only the pseudo-element's own + // declarations are scoped away. + // A plain rule keeps the static object form the compiler emits for it. + const rules = rulesFor(`.a { background-color: #ff0000; }`, "a"); + + expect(rules[0]?.d).toStrictEqual([{ backgroundColor: "#f00" }]); + }); +}); + +describe("::placeholder", () => { + test("color maps to placeholderTextColor", () => { + const rules = rulesFor(`.a::placeholder { color: #ff0000; }`, "a"); + + expect(rules[0]?.d).toStrictEqual([["#f00", ["placeholderTextColor"]]]); + }); + + test("an unmapped declaration is dropped, not applied to the element", () => { + const rules = rulesFor( + `.a::placeholder { background-color: #ff0000; }`, + "a", + ); + + expect(rules[0]?.d ?? []).toStrictEqual([]); + }); +}); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index c4abd1e0..b9562350 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -65,8 +65,13 @@ test("mixed", async () => { expect(component).toHaveStyle({ color: "#fff" }); }); +// `selection:bg-*`, not `selection:text-*`. `selectionColor` is the band +// painted BEHIND the selected text, which is `background-color` in CSS — +// `color` there is the selected TEXT's colour, and React Native has no prop +// for it. Mapping `color` inverted the meaning, so it is dropped now; see the +// case below. test("selection", async () => { - await render(); + await render(); const component = screen.getByTestId(testID); expect(component.props).toEqual({ @@ -77,6 +82,17 @@ test("selection", async () => { }); }); +test("selection: an unmappable declaration does not reach the element", async () => { + await render(); + + const component = screen.getByTestId(testID); + expect(component.props).toEqual({ + testID, + children: undefined, + style: {}, + }); +}); + test("ltr:", async () => { await render(); diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d379da8a..63d9cf54 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,13 +1,26 @@ import { isStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; +/** + * `::selection` maps `background-color` onto React Native's `selectionColor`. + * + * `background-color` rather than `color`, because they are opposites here: in + * CSS, `color` inside `::selection` is the colour of the selected TEXT, while + * React Native's `selectionColor` is the band painted BEHIND it. Mapping + * `color` renders a stylesheet asking for white selected text as a white band, + * leaving the text it meant to lighten sitting on top of it. + */ export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { if (!rule.d) { return; } rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "selectionColor"); + return modifyStyleDeclaration( + declaration, + "backgroundColor", + "selectionColor", + ); }); return rule; @@ -27,6 +40,16 @@ export function modifyRuleForPlaceholder( return rule; } +/** + * Map the ONE declaration the target platform can express, and DROP the rest. + * + * Dropping is the whole point. A pseudo-element's declarations are scoped to + * the pseudo-element, so returning an unmapped one unchanged applies it to the + * real element — `::selection { background-color: blue }` tinted the whole + * control rather than the selection. `[]` is the correct answer for something + * React Native has no prop for: not applying it is strictly better than + * applying it somewhere else. + */ function modifyStyleDeclaration( declaration: StyleDeclaration, from: string, @@ -42,13 +65,15 @@ function modifyStyleDeclaration( declaration[1] = [to]; return [declaration]; } + + return []; } else if (typeof declaration === "object") { - const { color: selectionColor, ...rest } = declaration; + const value = (declaration as Record)[from]; - if (selectionColor) { - return [rest, [selectionColor, [to]]] as StyleDeclaration[]; - } + return value === undefined + ? [] + : ([[value, [to]]] as unknown as StyleDeclaration[]); } - return [declaration]; + return []; } From c019afda37e9ce02f72a4b2cf8c3226f186c790f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 20:07:33 +0300 Subject: [PATCH 2/6] docs: trim comments to the surrounding one-line style pseudo-elements.ts carries no comments upstream, so the multi-paragraph blocks stood out. What is left is the two facts the code cannot state: that ::selection maps background-color rather than color, and that an unmapped declaration is dropped instead of returned. --- .../compiler/pseudo-elements.test.ts | 18 +++++---------- src/__tests__/vendor/tailwind/states.test.tsx | 7 ++---- src/compiler/pseudo-elements.ts | 23 ++++--------------- 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index 738950bf..de60d232 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -8,12 +8,8 @@ const rulesFor = (css: string, className: string): StyleRule[] => { []) as StyleRule[]; }; -/** - * A pseudo-element's declarations are scoped to the pseudo-element. The - * compiler maps ONE declaration onto a React Native prop; every other one must - * be DROPPED, not returned unchanged — returning it applies a `::selection` - * declaration to the real element. - */ +// The compiler maps one declaration onto a React Native prop and drops the rest; +// returning an unmapped one applies a ::selection declaration to the real element describe("::selection", () => { test("background-color maps to selectionColor", () => { const rules = rulesFor(`.a::selection { background-color: #ff0000; }`, "a"); @@ -37,18 +33,16 @@ describe("::selection", () => { }); test("color does not paint the element", () => { - // `color` inside ::selection is the selected TEXT colour, which React - // Native cannot express — mapping it to `selectionColor` would invert its - // meaning, so it is dropped rather than re-targeted. + // color here is the selected TEXT colour, which React Native cannot express; + // mapping it to selectionColor would invert its meaning const rules = rulesFor(`.a::selection { color: #ff0000; }`, "a"); expect(rules[0]?.d ?? []).toStrictEqual([]); }); test("a plain rule on the same class is untouched", () => { - // The control an over-broad fix breaks: only the pseudo-element's own - // declarations are scoped away. - // A plain rule keeps the static object form the compiler emits for it. + // The control an over-broad fix breaks: only a pseudo-element's own declarations + // are scoped away, and a plain rule keeps the static object form const rules = rulesFor(`.a { background-color: #ff0000; }`, "a"); expect(rules[0]?.d).toStrictEqual([{ backgroundColor: "#f00" }]); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index b9562350..7ce8c142 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -65,11 +65,8 @@ test("mixed", async () => { expect(component).toHaveStyle({ color: "#fff" }); }); -// `selection:bg-*`, not `selection:text-*`. `selectionColor` is the band -// painted BEHIND the selected text, which is `background-color` in CSS — -// `color` there is the selected TEXT's colour, and React Native has no prop -// for it. Mapping `color` inverted the meaning, so it is dropped now; see the -// case below. +// selection:bg-*, not selection:text-*. selectionColor is the band behind the selected +// text, which is background-color in CSS; color there has no React Native prop test("selection", async () => { await render(); diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index 63d9cf54..4852a0dd 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,15 +1,8 @@ import { isStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; -/** - * `::selection` maps `background-color` onto React Native's `selectionColor`. - * - * `background-color` rather than `color`, because they are opposites here: in - * CSS, `color` inside `::selection` is the colour of the selected TEXT, while - * React Native's `selectionColor` is the band painted BEHIND it. Mapping - * `color` renders a stylesheet asking for white selected text as a white band, - * leaving the text it meant to lighten sitting on top of it. - */ +// background-color, not color: in ::selection `color` is the selected TEXT, while +// selectionColor is the band painted behind it export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { if (!rule.d) { return; @@ -40,16 +33,8 @@ export function modifyRuleForPlaceholder( return rule; } -/** - * Map the ONE declaration the target platform can express, and DROP the rest. - * - * Dropping is the whole point. A pseudo-element's declarations are scoped to - * the pseudo-element, so returning an unmapped one unchanged applies it to the - * real element — `::selection { background-color: blue }` tinted the whole - * control rather than the selection. `[]` is the correct answer for something - * React Native has no prop for: not applying it is strictly better than - * applying it somewhere else. - */ +// Map the one declaration the platform can express and drop the rest. A pseudo-element's +// declarations are scoped to it, so returning an unmapped one applies it to the real element function modifyStyleDeclaration( declaration: StyleDeclaration, from: string, From 3d21329acef29ca023efa7201f7a84aaa0b568ca Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:08:48 +0300 Subject: [PATCH 3/6] fix(compiler): scope every pseudo-element field, not just declarations modifyRuleForSelection and modifyRuleForPlaceholder rewrote rule.d and left the rest of the rule alone, so a ::selection declaration still reached the host through the fields declarations.ts writes beside it: - color mirrors into --__rn-css-color, which every descendant reads as currentColor, so `.a::selection { color: red }` painted the whole subtree - font-size mirrors into --__rn-css-em, so `.a::selection { font-size: 40px }` became the element's em base - container-name registered the host as a named container - an animation or transition left `a: true` on a host with nothing to animate, and an unmapped var() left `dv: 1` on a host with no variable declaration Rebuild the rule rather than mutate it: carry over the fields the selector owns, recompute d and dv from the declarations that survive, drop the rest. A field policy over `keyof StyleRule` makes that classification exhaustive, so adding a field to StyleRule fails to compile until it is classified. A rule with nothing left is no longer registered at all, and every dropped property is reported through addWarning rather than vanishing silently. Delete two dead branches: index 2 of a StyleDeclaration is the delay flag and never a property name, and StyleDeclaration is a union of object shapes, so the trailing return sat past a total if/else. Move postProcessStyleFunction beside the other StyleDescriptor predicates so the scoping can recompute dv without importing back into the builder. --- README.md | 33 ++ .../compiler/pseudo-elements.test.ts | 305 ++++++++++++++++-- src/__tests__/native/pseudo-elements.test.tsx | 156 +++++++++ src/__tests__/vendor/tailwind/states.test.tsx | 14 +- src/compiler/pseudo-elements.ts | 168 +++++++--- src/compiler/stylesheet.ts | 65 ++-- src/utilities/style-descriptor.ts | 34 ++ 7 files changed, 653 insertions(+), 122 deletions(-) create mode 100644 src/__tests__/native/pseudo-elements.test.tsx diff --git a/README.md b/README.md index bafb2302..f2a6b00e 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,39 @@ This API only allows for setting CSS variables as primitive values. For more com > [!IMPORTANT] > By using `VariableContext` you may need to disable the `inlineVariable` optimization +## Pseudo-elements + +React Native has no pseudo-elements. It has two props that stand in for one declaration each, and `::selection` / `::placeholder` compile to those props: + +| CSS | React Native prop | On a | +| ---------------------------------- | ---------------------- | ----------- | +| `::selection { background-color }` | `selectionColor` | `TextInput` | +| `::placeholder { color }` | `placeholderTextColor` | `TextInput` | + +`::selection { background-color }`, not `::selection { color }`. In CSS `color` inside `::selection` is the colour of the selected text and `background-color` is the band painted behind it; React Native's `selectionColor` is that band. + +Every other declaration inside a pseudo-element is dropped, and the compiler reports it: + +```css +.input::selection { + background-color: red; /* → selectionColor */ + color: white; /* dropped */ + width: 10px; /* dropped */ +} +``` + +```js +compile(css).warnings(); +// { values: { "::selection": ["color", "width"] } } +``` + +They are dropped rather than applied because a pseudo-element's declarations belong to the pseudo-element. Applying them to the host would paint the element itself — a `::selection { color }` would set the element's text colour, and through `currentColor` its whole subtree. + +> [!IMPORTANT] +> This is native only. On web the CSS file is served to the browser unchanged, so `::selection` and `::placeholder` behave exactly as CSS specifies and no declaration is dropped. A rule that is meaningful on both platforms should say so in `background-color` for `::selection` and `color` for `::placeholder`; anything else styles the browser and nothing else. + +With Tailwind, the native prop comes from `selection:bg-*`. `selection:text-*` is `color` and is dropped on native, though it still works in a browser. + ## Optimizations CSS is a dynamic styling language that use highly optimized engines that are not available in React Native. Instead, we optimize the styles to improve performance diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index de60d232..0e25b975 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -1,67 +1,302 @@ import { compile } from "react-native-css/compiler"; import type { StyleRule } from "../../compiler/compiler.types"; +import { + pseudoElementFieldPolicy, + scopeRuleToPseudoElement, +} from "../../compiler/pseudo-elements"; -const rulesFor = (css: string, className: string): StyleRule[] => { - const result = compile(css).stylesheet(); - return (result.s?.find(([name]) => name === className)?.[1] ?? - []) as StyleRule[]; +interface CompiledClass { + /** Every field of every rule except `s`, which the selector owns rather than the declarations */ + rules: Partial[]; + warnings: ReturnType["warnings"]>; +} + +/** + * Reads the whole rule, not just `d`. A pseudo-element declaration reaches the element through + * any field a declaration can set — `v` carries the --__rn-css-color / --__rn-css-em mirrors + * declarations.ts writes beside `color` and `font-size`, `c` registers a named container, and + * `a` / `dv` make the host animated or variable-driven + */ +const compileFor = (css: string, className = "a"): CompiledClass => { + const compiled = compile(css); + const rules = (compiled + .stylesheet() + .s?.find(([name]) => name === className)?.[1] ?? []) as StyleRule[]; + + return { + rules: rules.map((rule) => { + const fields: Partial = { ...rule }; + delete fields.s; + return fields; + }), + warnings: compiled.warnings(), + }; }; -// The compiler maps one declaration onto a React Native prop and drops the rest; -// returning an unmapped one applies a ::selection declaration to the real element describe("::selection", () => { - test("background-color maps to selectionColor", () => { - const rules = rulesFor(`.a::selection { background-color: #ff0000; }`, "a"); + test("background-color maps to selectionColor and sets nothing else", () => { + expect( + compileFor(`.a::selection { background-color: #ff0000; }`), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: {}, + }); + }); + + test("color paints neither the element nor its subtree", () => { + // `color` in ::selection is the selected TEXT colour, which React Native cannot express. + // declarations.ts mirrors every `color` into --__rn-css-color, which every descendant reads + // as currentColor, so dropping it from `d` alone leaves the subtree painted + expect(compileFor(`.a::selection { color: #ff0000; }`)).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["color"] } }, + }); + }); - expect(rules[0]?.d).toStrictEqual([["#f00", ["selectionColor"]]]); + test("font-size does not become the element's em base", () => { + // declarations.ts mirrors every `font-size` into --__rn-css-em, which every em unit on the + // element resolves against + expect(compileFor(`.a::selection { font-size: 40px; }`)).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["fontSize"] } }, + }); }); - test("an unmapped declaration is dropped, not applied to the element", () => { - const rules = rulesFor(`.a::selection { width: 10px; }`, "a"); + test("an unmapped static declaration is dropped", () => { + expect( + compileFor(`.a::selection { width: 10px; height: 5px; }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["width", "height"] } }, + }); + }); + + test("an unmapped var() declaration is dropped", () => { + // A var() compiles to a style-function tuple rather than the static object every other + // "unmapped is dropped" case takes, so it exercises the other branch of the scoping + expect(compileFor(`.a::selection { width: var(--x); }`)).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["width"] } }, + }); + }); - expect(rules[0]?.d ?? []).toStrictEqual([]); + test("an unmapped style-function declaration is dropped", () => { + expect( + compileFor(`.a::selection { transform: translateX(10px); }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["transform"] } }, + }); }); test("an unmapped declaration beside a mapped one is still dropped", () => { - const rules = rulesFor( - `.a::selection { background-color: #ff0000; width: 10px; }`, - "a", + expect( + compileFor(`.a::selection { background-color: #ff0000; width: 10px; }`), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["width"] } }, + }); + }); + + test("an animation leaves no animated rule behind", () => { + // `a` makes the host render through an animated component. With every animation + // declaration scoped away there is nothing left for it to animate + const { rules, warnings } = compileFor( + `.a::selection { animation: spin 1s; }`, ); - expect(rules[0]?.d).toStrictEqual([["#f00", ["selectionColor"]]]); + expect(rules).toStrictEqual([]); + expect(warnings.values?.["::selection"]).toContain("animationName"); }); - test("color does not paint the element", () => { - // color here is the selected TEXT colour, which React Native cannot express; - // mapping it to selectionColor would invert its meaning - const rules = rulesFor(`.a::selection { color: #ff0000; }`, "a"); + test("a transition beside a mapped declaration does not animate the element", () => { + const { rules, warnings } = compileFor( + `.a::selection { background-color: #ff0000; transition: background-color 1s; }`, + ); + + expect(rules).toStrictEqual([{ d: [["#f00", ["selectionColor"]]] }]); + expect(warnings.values?.["::selection"]).toContain("transitionProperty"); + }); + + test("container-name does not make the element a container", () => { + // container-name is the one authored declaration that never reaches `d` + expect( + compileFor( + `.a::selection { background-color: #ff0000; container-name: foo; }`, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["container-name"] } }, + }); + }); + + test("a mapped var() declaration keeps its variable subscription", () => { + // The counterpart of the drops above: `dv` is rebuilt, not blanket-cleared, or the + // runtime stops resolving the variable the surviving declaration reads + expect( + compileFor(`.a::selection { background-color: var(--x); }`), + ).toStrictEqual({ + rules: [{ d: [[[{}, "var", "x", 1], ["selectionColor"], 1]], dv: 1 }], + warnings: {}, + }); + }); + + test("the selector's own conditions survive the scoping", () => { + expect( + compileFor( + `@media (min-width: 100px) { .a:hover[data-x]::selection { background-color: #ff0000; } }`, + ), + ).toStrictEqual({ + rules: [ + { + d: [["#f00", ["selectionColor"]]], + m: [[">=", "width", 100]], + p: { h: 1 }, + aq: [["d", "x"]], + }, + ], + warnings: {}, + }); + }); - expect(rules[0]?.d ?? []).toStrictEqual([]); + test("a container query survives the scoping", () => { + expect( + compileFor( + `@container (min-width: 100px) { .a::selection { background-color: #ff0000; } }`, + ), + ).toStrictEqual({ + rules: [ + { + d: [["#f00", ["selectionColor"]]], + cq: [{ m: [">=", "width", 100] }], + }, + ], + warnings: {}, + }); }); - test("a plain rule on the same class is untouched", () => { - // The control an over-broad fix breaks: only a pseudo-element's own declarations - // are scoped away, and a plain rule keeps the static object form - const rules = rulesFor(`.a { background-color: #ff0000; }`, "a"); + test("one authored rule warns once however many selectors it expands to", () => { + const { warnings } = compileFor( + `.a::selection, .b::selection { width: 10px; }`, + ); + + expect(warnings).toStrictEqual({ values: { "::selection": ["width"] } }); + }); - expect(rules[0]?.d).toStrictEqual([{ backgroundColor: "#f00" }]); + test("the README example compiles to what the README says", () => { + expect( + compileFor( + `.input::selection { background-color: red; color: white; width: 10px; }`, + "input", + ).warnings, + ).toStrictEqual({ values: { "::selection": ["color", "width"] } }); }); }); describe("::placeholder", () => { - test("color maps to placeholderTextColor", () => { - const rules = rulesFor(`.a::placeholder { color: #ff0000; }`, "a"); + test("color maps to placeholderTextColor and sets nothing else", () => { + // The --__rn-css-color mirror leaks here too, even though the declaration IS mapped: + // placeholderTextColor is the placeholder's colour, never the element's currentColor + expect(compileFor(`.a::placeholder { color: #ff0000; }`)).toStrictEqual({ + rules: [{ d: [["#f00", ["placeholderTextColor"]]] }], + warnings: {}, + }); + }); - expect(rules[0]?.d).toStrictEqual([["#f00", ["placeholderTextColor"]]]); + test("an unmapped declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: #ff0000; }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::placeholder": ["backgroundColor"] } }, + }); }); - test("an unmapped declaration is dropped, not applied to the element", () => { - const rules = rulesFor( - `.a::placeholder { background-color: #ff0000; }`, - "a", - ); + test("an unmapped var() declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: var(--x); }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::placeholder": ["backgroundColor"] } }, + }); + }); +}); + +describe("rules without a pseudo-element", () => { + test("a plain rule on the same class keeps every field", () => { + // Control for an over-broad fix: scoping runs per selector, so a rule that reaches the + // element directly keeps its static object AND the --__rn-css-color mirror + expect(compileFor(`.a { color: #ff0000; }`)).toStrictEqual({ + rules: [{ d: [{ color: "#f00" }], v: [["__rn-css-color", "#f00"]] }], + warnings: {}, + }); + }); + + test("the unscoped half of a grouped selector is untouched", () => { + // Both selectors share one rule object, so scoping the pseudo-element half by mutation + // rather than by rebuilding would strip the plain half too + const css = `.a::selection, .b { background-color: #ff0000; }`; + + expect(compileFor(css, "a").rules).toStrictEqual([ + { d: [["#f00", ["selectionColor"]]] }, + ]); + expect(compileFor(css, "b").rules).toStrictEqual([ + { d: [{ backgroundColor: "#f00" }] }, + ]); + }); +}); + +describe("field policy", () => { + const policyFields = Object.keys(pseudoElementFieldPolicy).filter( + (key): key is keyof typeof pseudoElementFieldPolicy => + key in pseudoElementFieldPolicy, + ); + + test("the policy classifies at least one field of each kind", () => { + // Without this, an empty or single-kind policy would make the table below assert nothing + for (const kind of ["selector", "rebuilt", "dropped"] as const) { + expect( + policyFields.filter( + (field) => pseudoElementFieldPolicy[field] === kind, + ), + ).not.toStrictEqual([]); + } + }); + + test("a selector field is carried over and a dropped field never is", () => { + // Driven off the policy rather than a hand-written list, so classifying a new StyleRule + // field here is what puts it under test + const populated: StyleRule = { + s: [1, 1], + d: [["#f00", "backgroundColor"]], + v: [["__rn-css-color", "#f00"]], + c: ["c:foo"], + dv: 1, + a: true, + target: "style", + m: [[">=", "width", 100]], + p: { h: 1 }, + cq: [{ m: [">=", "width", 100] }], + aq: [["d", "x"]], + }; + + const { rule: scoped } = scopeRuleToPseudoElement(populated, "selection"); + + expect(scoped).toBeDefined(); - expect(rules[0]?.d ?? []).toStrictEqual([]); + for (const field of policyFields) { + switch (pseudoElementFieldPolicy[field]) { + case "selector": + expect(scoped).toHaveProperty(field, populated[field]); + break; + case "dropped": + expect(scoped).not.toHaveProperty(field); + break; + case "rebuilt": + // Asserted by the behaviour tests above, which pin what each is rebuilt from + break; + } + } }); }); diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx new file mode 100644 index 00000000..c15f7c02 --- /dev/null +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -0,0 +1,156 @@ +import { render, screen } from "@testing-library/react-native"; +import { TextInput } from "react-native-css/components/TextInput"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +const controlTestID = "control"; + +/** + * Every case here renders the pseudo-element declaration beside a control that carries only + * what the platform can express, and asserts the two are indistinguishable. That keeps the + * expectation derived rather than a literal copied out of a passing run, and keeps it free of + * the platform-specific values a semantic colour or a default rem would otherwise pin + */ +const propsWithoutTestID = (id: string): Record => { + const { testID: _testID, ...props } = screen.getByTestId(id).props; + return props; +}; + +test("::selection { color } does not publish currentcolor to the subtree", () => { + // declarations.ts mirrors `color` into --__rn-css-color, which every descendant reads as + // currentColor. Scoping only `d` leaves the whole subtree painted the selection colour + registerCSS(` + .sel::selection { color: #ff0000; } + .child { color: currentColor; } + `); + + render( + + + + + + , + ); + + const scoped = propsWithoutTestID(testID); + + expect(scoped).toStrictEqual(propsWithoutTestID(controlTestID)); + expect(scoped.style).not.toStrictEqual({ color: "#f00" }); +}); + +test("::selection { font-size } does not become the element's em base", () => { + // font-size is mirrored into --__rn-css-em, which every em on the element resolves against + registerCSS(` + .a::selection { font-size: 40px; } + .a { width: 2em; } + .control { width: 2em; } + `); + + render( + + + + , + ); + + expect(propsWithoutTestID(testID)).toStrictEqual( + propsWithoutTestID(controlTestID), + ); +}); + +test("::selection { animation } does not render the host as animated", () => { + // `a` swaps the host for an animated component. Every animation declaration is scoped away, + // so there is nothing left for it to animate + registerCSS(` + .a::selection { animation: spin 1s; } + `); + + render( + + + + , + ); + + expect(propsWithoutTestID(testID)).toStrictEqual( + propsWithoutTestID(controlTestID), + ); +}); + +test("::selection { container-name } does not turn the host into a container", () => { + // `c` registers the host as a named container, which adds onLayout measurement and the + // focus/press handlers a container query needs + registerCSS(` + .a::selection { background-color: #ff0000; container-name: foo; } + .control::selection { background-color: #ff0000; } + `); + + render( + + + + , + ); + + expect(propsWithoutTestID(testID)).toStrictEqual( + propsWithoutTestID(controlTestID), + ); +}); + +test("::selection { background-color } still reaches selectionColor", () => { + registerCSS(`.a::selection { background-color: #ff0000; }`); + + render(); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + selectionColor: "#f00", + style: {}, + testID, + }); +}); + +test("::selection { background-color: var() } still resolves an inherited variable", () => { + // The variable lives on an ancestor, so the host only reads it because the scoped rule kept + // its `dv` flag. Blanket-clearing the declaration-derived fields would break this + registerCSS(` + .parent { --x: #ff0000; } + .a::selection { background-color: var(--x); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + selectionColor: "#f00", + style: {}, + testID, + }); +}); + +test("::placeholder { color } reaches placeholderTextColor and nothing else", () => { + // `color` IS the mapped declaration here, and it still mirrors into --__rn-css-color: the + // placeholder's colour must not become the input's currentColor + registerCSS(` + .a::placeholder { color: #ff0000; } + .child { color: currentColor; } + `); + + render( + + + + + + , + ); + + expect(propsWithoutTestID(testID)).toStrictEqual( + propsWithoutTestID(controlTestID), + ); +}); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index 7ce8c142..33ad51f0 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -80,13 +80,19 @@ test("selection", async () => { }); test("selection: an unmappable declaration does not reach the element", async () => { - await render(); + // selection:text-* is `color` inside ::selection — the selected TEXT colour, which has no + // React Native prop. Nothing reaches the element and the compiler says what it dropped + const { warnings } = await render( + , + ); - const component = screen.getByTestId(testID); - expect(component.props).toEqual({ + expect(screen.getByTestId(testID).props).toEqual({ testID, children: undefined, - style: {}, + }); + + expect(warnings()).toStrictEqual({ + values: { "::selection": ["color"] }, }); }); diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index 4852a0dd..64cd5981 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,64 +1,148 @@ -import { isStyleFunction } from "../utilities"; +import { postProcessStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; -// background-color, not color: in ::selection `color` is the selected TEXT, while -// selectionColor is the band painted behind it -export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { - if (!rule.d) { - return; - } +/** + * The one declaration each pseudo-element can express on the host component, and the + * React Native prop it becomes. + * + * ::selection maps background-color, not color: in CSS `::selection { color }` is the + * selected TEXT, while selectionColor is the band painted behind it + */ +const pseudoElementProp = { + selection: ["backgroundColor", "selectionColor"], + placeholder: ["color", "placeholderTextColor"], +} as const satisfies Record; - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration( - declaration, - "backgroundColor", - "selectionColor", - ); - }); +export type PseudoElement = keyof typeof pseudoElementProp; + +const pseudoElements: PseudoElement[] = Object.keys(pseudoElementProp).filter( + (key): key is PseudoElement => key in pseudoElementProp, +); + +/** + * What scoping does with each StyleRule field. A `selector` field describes which elements + * the rule matches and is carried over; a `rebuilt` field is recomputed from the declarations + * that survive; a `dropped` field belongs to the pseudo-element and never reaches the host. + * + * `satisfies` makes this total over StyleRule, so a new field fails to compile until it is + * classified. That is what stops the next declaration-derived field escaping the + * pseudo-element the way `v`, `c`, `dv` and `a` did while only `d` was rewritten + */ +export const pseudoElementFieldPolicy = { + s: "selector", + m: "selector", + p: "selector", + cq: "selector", + aq: "selector", + d: "rebuilt", + dv: "rebuilt", + v: "dropped", + c: "dropped", + a: "dropped", + target: "dropped", +} as const satisfies Record< + keyof StyleRule, + "selector" | "rebuilt" | "dropped" +>; + +export interface ScopedRule { + /** The rule to register, or undefined when no declaration survived the scoping */ + rule: StyleRule | undefined; + /** React Native properties the pseudo-element cannot express, in declaration order */ + dropped: string[]; +} - return rule; +export function getPseudoElement( + pseudoElementQuery: string[], +): PseudoElement | undefined { + for (const pseudoElement of pseudoElements) { + if (pseudoElementQuery.includes(pseudoElement)) { + return pseudoElement; + } + } + + return undefined; } -export function modifyRuleForPlaceholder( +/** + * Rebuild a rule so it carries only what the pseudo-element can express: the one mapped + * declaration, under the selector's own conditions. Every other declaration is the + * pseudo-element's own and would paint the host element if it were carried over + */ +export function scopeRuleToPseudoElement( rule: StyleRule, -): StyleRule | undefined { - if (!rule.d) { - return; + pseudoElement: PseudoElement, +): ScopedRule { + const [from, to] = pseudoElementProp[pseudoElement]; + + const declarations: StyleDeclaration[] = []; + const dropped: string[] = []; + + for (const declaration of rule.d ?? []) { + scopeDeclaration(declaration, from, to, declarations, dropped); } - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); - }); + // container-name is the only authored declaration that never reaches `d`. Entries in `v` + // are the compiler's own --__rn-css-* mirrors of a `d` declaration already reported here, + // and `a` is a flag over animation/transition declarations reported the same way + if (rule.c?.length) { + dropped.push("container-name"); + } - return rule; + if (!declarations.length) { + return { rule: undefined, dropped }; + } + + const scoped: StyleRule = { s: rule.s, d: declarations }; + + if (rule.m) scoped.m = rule.m; + if (rule.p) scoped.p = rule.p; + if (rule.cq) scoped.cq = rule.cq; + if (rule.aq) scoped.aq = rule.aq; + + if (declarations.some(usesVariables)) { + scoped.dv = 1; + } + + return { rule: scoped, dropped }; } -// Map the one declaration the platform can express and drop the rest. A pseudo-element's -// declarations are scoped to it, so returning an unmapped one applies it to the real element -function modifyStyleDeclaration( +function scopeDeclaration( declaration: StyleDeclaration, from: string, to: string, -): StyleDeclaration[] { + declarations: StyleDeclaration[], + dropped: string[], +): void { if (Array.isArray(declaration)) { - if (isStyleFunction(declaration) && declaration[2] === from) { - declaration = [...declaration] as StyleDeclaration; - declaration[2] = [to]; - return [declaration]; - } else if (declaration[1] === from) { - declaration = [...declaration] as StyleDeclaration; - declaration[1] = [to]; - return [declaration]; + const path = declaration[1]; + const property = Array.isArray(path) ? path.join(".") : path; + + if (property !== from) { + dropped.push(property); + return; } - return []; - } else if (typeof declaration === "object") { - const value = (declaration as Record)[from]; + declarations.push( + declaration.length === 3 + ? [declaration[0], [to], declaration[2]] + : [declaration[0], [to]], + ); + + return; + } - return value === undefined - ? [] - : ([[value, [to]]] as unknown as StyleDeclaration[]); + for (const [property, value] of Object.entries(declaration)) { + if (property === from) { + declarations.push([value, [to]]); + } else { + dropped.push(property); + } } +} - return []; +function usesVariables(declaration: StyleDeclaration): boolean { + return ( + Array.isArray(declaration) && postProcessStyleFunction(declaration[0])[1] + ); } diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..f5edfce0 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -1,7 +1,7 @@ import type { SelectorList } from "lightningcss"; import { - isStyleDescriptorArray, + postProcessStyleFunction, Specificity, specificityCompareFn, } from "../utilities"; @@ -22,8 +22,9 @@ import type { VariableValue, } from "./compiler.types"; import { - modifyRuleForPlaceholder, - modifyRuleForSelection, + getPseudoElement, + scopeRuleToPseudoElement, + type PseudoElement, } from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; @@ -447,15 +448,31 @@ export class StylesheetBuilder { this.options, ); + const warnedPseudoElements = new Set(); + for (const selector of normalizedSelectors) { // We are going to be apply the current rule to n selectors, so we clone the rule let rule: StyleRule | undefined = this.cloneRule(this.rule); if (selector.type === "className" && selector.pseudoElementQuery) { - if (selector.pseudoElementQuery.includes("selection")) { - rule = modifyRuleForSelection(rule); - } else if (selector.pseudoElementQuery.includes("placeholder")) { - rule = modifyRuleForPlaceholder(rule); + const pseudoElement = getPseudoElement(selector.pseudoElementQuery); + + if (pseudoElement) { + const scoped = scopeRuleToPseudoElement(rule, pseudoElement); + + // A supported property dropped for being in the wrong scope warns like an + // unsupported one, keyed by the pseudo-element it was scoped out of. Every + // selector scopes the same clone, so one authored rule reports once however + // many selectors it expands to + if (!warnedPseudoElements.has(pseudoElement)) { + warnedPseudoElements.add(pseudoElement); + + for (const property of scoped.dropped) { + this.addWarning("style", `::${pseudoElement}`, property); + } + } + + rule = scoped.rule; } } @@ -617,40 +634,6 @@ function isStyleFunction( ); } -function postProcessStyleFunction(value: StyleDescriptor): [ - // Should it be delayed - boolean, - // Does it use variables - boolean, -] { - if (!Array.isArray(value)) { - return [false, false]; - } - - if (isStyleDescriptorArray(value)) { - let shouldDelay = false; - let usesVariables = false; - for (const v of value) { - const [delayed, variables] = postProcessStyleFunction(v); - shouldDelay ||= delayed; - usesVariables ||= variables; - } - - return [shouldDelay, usesVariables]; - } - - let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - - usesVariables ||= value[1] === "var"; - shouldDelay ||= value[3] === 1 || usesVariables; - - if (shouldDelay) { - return [true, usesVariables]; - } - - return [false, false]; -} - function allEqual(...params: unknown[]) { return params.every((param, index, array) => { return index === 0 ? true : equal(array[0], param); diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 1310d62b..c7e789cb 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -22,3 +22,37 @@ export function isStyleFunction( return false; } + +export function postProcessStyleFunction(value: StyleDescriptor): [ + // Should it be delayed + boolean, + // Does it use variables + boolean, +] { + if (!Array.isArray(value)) { + return [false, false]; + } + + if (isStyleDescriptorArray(value)) { + let shouldDelay = false; + let usesVariables = false; + for (const v of value) { + const [delayed, variables] = postProcessStyleFunction(v); + shouldDelay ||= delayed; + usesVariables ||= variables; + } + + return [shouldDelay, usesVariables]; + } + + let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); + + usesVariables ||= value[1] === "var"; + shouldDelay ||= value[3] === 1 || usesVariables; + + if (shouldDelay) { + return [true, usesVariables]; + } + + return [false, false]; +} From d6fbceba23926127c792b10f4790590c3ea9f9e2 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 16:41:20 +0300 Subject: [PATCH 4/6] fix(compiler): report what a pseudo-element drops, accurately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `&` and `[n]` are the compiler's own path routing rather than part of a property name, so a dropped `text-shadow` was reported as `&.textShadowOffset.width` — a name that appears nowhere the author can act on. Render the path the way the runtime reads it: `textShadowOffset.width`, `boxShadow[0].color`. `container-name`, `container-type` and the `container` shorthand all reach `c` without passing through `d`, and `c` records only the name, so a dropped `container-type` was reported as `container-name` — a property the author never wrote. Report the family instead, which is true of all three. Six discriminators had no coverage; mutating the source survived the whole suite for each. Tests now pin: a nested property path, each of the three container spellings, `container-name: none` (which empties `c` rather than leaving it absent, so the report is guarded on the entries), a rule whose `d` holds more than one entry, a second pseudo-element inside one authored rule, and a delayed declaration that reads no variable and so must not set `dv`. The comment above the container report claimed `container-name` was the only authored declaration that never reaches `d`, and that every `v` entry mirrors a `d` declaration already reported. Both are false: `container-type`, the `container` shorthand and any authored custom property also bypass `d`, and `v` carries authored custom properties as well as the compiler's `--__rn-css-*` mirrors — so a `--x` written inside a pseudo-element is dropped with no report. The comment now says so. The README presented `compile(css).warnings()` as the way a drop surfaces. It is not: `metro-transformer` calls only `.stylesheet()`, so a `expo start` build prints nothing and the only thing an author observes is a declaration that has no effect. The README says that, and names the two callers that do read it. BREAKING CHANGE: `::selection { color }` no longer reaches the element. It compiled to `selectionColor`, which is the band painted behind the selected text rather than the text itself; `background-color` maps to `selectionColor` instead. A stylesheet using Tailwind's `selection:text-*` loses its highlight on upgrade, and `selection:bg-*` is the equivalent. Every other declaration inside `::selection` / `::placeholder` is dropped rather than applied to the host, so a rule that painted the whole control stops painting it. A rule with no surviving declaration is no longer registered: its class key disappears from `stylesheet().s` rather than appearing with a stripped rule. And `compile(css).warnings()` gains entries under the synthetic keys `"::selection"` and `"::placeholder"`, which fails any downstream `toStrictEqual` over `warnings()` for CSS containing a pseudo-element. --- README.md | 4 +- .../compiler/pseudo-elements.test.ts | 105 +++++++++++++++++- src/__tests__/native/pseudo-elements.test.tsx | 2 +- src/compiler/pseudo-elements.ts | 41 +++++-- 4 files changed, 139 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f2a6b00e..338a6b58 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ React Native has no pseudo-elements. It has two props that stand in for one decl `::selection { background-color }`, not `::selection { color }`. In CSS `color` inside `::selection` is the colour of the selected text and `background-color` is the band painted behind it; React Native's `selectionColor` is that band. -Every other declaration inside a pseudo-element is dropped, and the compiler reports it: +Every other declaration inside a pseudo-element is dropped: ```css .input::selection { @@ -256,6 +256,8 @@ Every other declaration inside a pseudo-element is dropped, and the compiler rep } ``` +The compiler records each drop, but nothing in the Metro pipeline reads that record — a `expo start` build prints nothing, and the only thing you observe is that the declaration has no effect on native. Two places do read it: `compile()`, and a jest test through `registerCSS(css, { debug: true })`. + ```js compile(css).warnings(); // { values: { "::selection": ["color", "width"] } } diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index 0e25b975..4c950f02 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -119,15 +119,100 @@ describe("::selection", () => { expect(warnings.values?.["::selection"]).toContain("transitionProperty"); }); - test("container-name does not make the element a container", () => { - // container-name is the one authored declaration that never reaches `d` + test.each([ + "container-name: foo", + "container-type: inline-size", + "container: foo / inline-size", + ])("`%s` does not make the element a container", (declaration) => { + // All three reach `c` without passing through `d`, and `c` records only the name, so the + // report names the family rather than claiming the user wrote one of the three expect( compileFor( - `.a::selection { background-color: #ff0000; container-name: foo; }`, + `.a::selection { background-color: #ff0000; ${declaration}; }`, ), ).toStrictEqual({ rules: [{ d: [["#f00", ["selectionColor"]]] }], - warnings: { values: { "::selection": ["container-name"] } }, + warnings: { values: { "::selection": ["container"] } }, + }); + }); + + test("container-name: none registers no container and reports no drop", () => { + // `none` empties `c` rather than leaving it absent, so a report guarded on the field + // rather than on its entries would warn about a container that was never registered + expect( + compileFor( + `.a::selection { background-color: #ff0000; container-name: none; }`, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: {}, + }); + }); + + test("every declaration is scoped, not only the first", () => { + // A static object and a style-function tuple are separate `d` entries. Every other case + // here has one entry, so this is the shape where scoping the first and stopping is + // invisible: `background-color` survives either way and only `transform` says otherwise + expect( + compileFor( + `.a::selection { background-color: #ff0000; transform: translateX(1px); }`, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["transform"] } }, + }); + }); + + test("a nested property path is reported the way the runtime reads it", () => { + // `&` routes a path to the top level instead of nesting it under its first segment, and + // `[n]` is an index. Neither is part of the property, and a user cannot act on either + expect( + compileFor(`.a::selection { text-shadow: 1px 2px 3px red; }`).warnings, + ).toStrictEqual({ + values: { + "::selection": [ + "textShadowColor", + "textShadowRadius", + "textShadowOffset.width", + "textShadowOffset.height", + ], + }, + }); + + expect( + compileFor(`.a::selection { box-shadow: 1px 2px 3px red; }`).warnings, + ).toStrictEqual({ + values: { + "::selection": [ + "boxShadow[0].color", + "boxShadow[0].offsetX", + "boxShadow[0].offsetY", + "boxShadow[0].blurRadius", + "boxShadow[0].spreadDistance", + ], + }, + }); + }); + + test("a delayed declaration that reads no variable does not set dv", () => { + // `em` makes a declaration delayed without making it variable-driven. `dv` is the + // variable subscription, so rebuilding it from the delay flag would have the runtime + // resolve variables this declaration never reads + expect( + compileFor(`.a::selection { background-color: hsl(calc(1em) 50% 50%); }`), + ).toStrictEqual({ + rules: [ + { + d: [ + [ + [{}, "hsl", [[{}, "calc", [[{}, "em", 1, 1]]], "50%", "50%"]], + ["selectionColor"], + 1, + ], + ], + }, + ], + warnings: {}, }); }); @@ -184,6 +269,18 @@ describe("::selection", () => { expect(warnings).toStrictEqual({ values: { "::selection": ["width"] } }); }); + test("each pseudo-element in one authored rule reports its own drops", () => { + // The counterpart of the dedupe above: it is keyed by pseudo-element, so one authored + // rule that expands to two DIFFERENT pseudo-elements reports under both + const { warnings } = compileFor( + `.a::selection, .b::placeholder { width: 10px; }`, + ); + + expect(warnings).toStrictEqual({ + values: { "::selection": ["width"], "::placeholder": ["width"] }, + }); + }); + test("the README example compiles to what the README says", () => { expect( compileFor( diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx index c15f7c02..f8bac64d 100644 --- a/src/__tests__/native/pseudo-elements.test.tsx +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -133,7 +133,7 @@ test("::selection { background-color: var() } still resolves an inherited variab }); }); -test("::placeholder { color } reaches placeholderTextColor and nothing else", () => { +test("::placeholder { color } does not publish currentcolor to the subtree", () => { // `color` IS the mapped declaration here, and it still mirrors into --__rn-css-color: the // placeholder's colour must not become the input's currentColor registerCSS(` diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index 64cd5981..b0a4e850 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -48,7 +48,7 @@ export const pseudoElementFieldPolicy = { export interface ScopedRule { /** The rule to register, or undefined when no declaration survived the scoping */ rule: StyleRule | undefined; - /** React Native properties the pseudo-element cannot express, in declaration order */ + /** What the pseudo-element cannot express, in declaration order */ dropped: string[]; } @@ -82,13 +82,18 @@ export function scopeRuleToPseudoElement( scopeDeclaration(declaration, from, to, declarations, dropped); } - // container-name is the only authored declaration that never reaches `d`. Entries in `v` - // are the compiler's own --__rn-css-* mirrors of a `d` declaration already reported here, - // and `a` is a flag over animation/transition declarations reported the same way + // `c` and `v` are the fields an authored declaration reaches without passing through `d`. + // Every `c` entry comes from container-name, container-type or the container shorthand, so + // the report names the family rather than picking one of the three. `a` is only ever set + // beside the `d` entry that set it, so it is already reported through that entry if (rule.c?.length) { - dropped.push("container-name"); + dropped.push("container"); } + // `v` is not reported. It holds the compiler's own --__rn-css-* mirrors of a `d` + // declaration already reported here, and also any authored custom property, so a `--x` + // written inside a pseudo-element is dropped silently + if (!declarations.length) { return { rule: undefined, dropped }; } @@ -115,8 +120,7 @@ function scopeDeclaration( dropped: string[], ): void { if (Array.isArray(declaration)) { - const path = declaration[1]; - const property = Array.isArray(path) ? path.join(".") : path; + const property = toPropertyName(declaration[1]); if (property !== from) { dropped.push(property); @@ -141,6 +145,29 @@ function scopeDeclaration( } } +/** + * The React Native property a declaration writes, spelled the way the runtime reads it. A + * leading `&` marks a path written at the top level rather than nested under its first + * segment, so it is routing rather than part of the name, and a `[n]` segment is an index + */ +function toPropertyName(path: string | string[]): string { + if (!Array.isArray(path)) { + return path; + } + + return path.reduce((name, segment, index) => { + if (index === 0 && segment === "&") { + return name; + } + + if (segment.startsWith("[")) { + return `${name}${segment}`; + } + + return name ? `${name}.${segment}` : segment; + }, ""); +} + function usesVariables(declaration: StyleDeclaration): boolean { return ( Array.isArray(declaration) && postProcessStyleFunction(declaration[0])[1] From 062fbb4e573bdcbdadedaf591741f78f263089b7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 16:51:34 +0300 Subject: [PATCH 5/6] docs(compiler): fold the v-drop note into the block it describes The note sat between the container report and the empty-declaration guard, so it read as documentation of the guard rather than of the field it names. --- src/compiler/pseudo-elements.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index b0a4e850..d9e089f0 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -84,16 +84,15 @@ export function scopeRuleToPseudoElement( // `c` and `v` are the fields an authored declaration reaches without passing through `d`. // Every `c` entry comes from container-name, container-type or the container shorthand, so - // the report names the family rather than picking one of the three. `a` is only ever set - // beside the `d` entry that set it, so it is already reported through that entry + // the report names the family rather than picking one of the three. `v` is not reported at + // all: it holds the compiler's own --__rn-css-* mirrors of a `d` declaration already + // reported here alongside any authored custom property, so a `--x` written inside a + // pseudo-element is dropped silently. `a` is only ever set beside the `d` entry that set + // it, so it is already reported through that entry if (rule.c?.length) { dropped.push("container"); } - // `v` is not reported. It holds the compiler's own --__rn-css-* mirrors of a `d` - // declaration already reported here, and also any authored custom property, so a `--x` - // written inside a pseudo-element is dropped silently - if (!declarations.length) { return { rule: undefined, dropped }; } From 55cc11b5c740d549e44b7431ff46800821443271 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 20:48:59 +0300 Subject: [PATCH 6/6] fix(compiler): report an authored custom property a pseudo-element drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom property is the one authored declaration that lands in `v` rather than `d`, so the field policy scopes it out and the declaration loop never sees it. `.a::selection { --brand: blue }` therefore lost `--brand` and `warnings()` stayed empty. `v` also holds the compiler's own mirrors — `--__rn-css-color` and `--__rn-css-em` beside `color` and `font-size`, `--__rn-css-direction` beside `direction` — each sitting next to a `d` declaration the report already names. Reporting the field wholesale would add a variable the user never wrote to every rule that sets a colour or a font size, so the mirrors are filtered by the namespace they are minted in, named at the filter site and tied to the mint sites by a test that reads the names off a compiled rule. The drop itself is unchanged: `v` was already classified `dropped`, and a native test now covers an authored name on a rule that keeps a mapped declaration, which is the shape where a carried-over `v` reaches the host. With `inlineVariables` left on, a custom property declared once is substituted into its uses and removed before any rule is built, so nothing reaches the pseudo-element and nothing is reported. `inlineVariables: false` keeps it, which is the configuration the README's `VariableContext` section asks for and where the silent drop costs the most. --- README.md | 10 ++ .../compiler/pseudo-elements.test.ts | 117 +++++++++++++++++- src/__tests__/native/pseudo-elements.test.tsx | 27 ++++ src/compiler/pseudo-elements.ts | 27 ++-- 4 files changed, 168 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 338a6b58..933a0cad 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,16 @@ compile(css).warnings(); They are dropped rather than applied because a pseudo-element's declarations belong to the pseudo-element. Applying them to the host would paint the element itself — a `::selection { color }` would set the element's text colour, and through `currentColor` its whole subtree. +A custom property is dropped the same way, and for the same reason: it would land on the host as a variable and every descendant would read it. + +```css +.input::selection { + --brand: blue; /* dropped, reported as "--brand" */ +} +``` + +With `inlineVariables` left on, a custom property declared once is substituted into its uses and its declaration removed before the pseudo-element is scoped at all — nothing reaches the pseudo-element, so nothing is dropped and nothing is reported. `inlineVariables: false`, the setting the `VariableContext` section above asks for, keeps every declaration, and that is where this drop costs the most. + > [!IMPORTANT] > This is native only. On web the CSS file is served to the browser unchanged, so `::selection` and `::placeholder` behave exactly as CSS specifies and no declaration is dropped. A rule that is meaningful on both platforms should say so in `background-color` for `::selection` and `color` for `::placeholder`; anything else styles the browser and nothing else. diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index 4c950f02..5b6c943a 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -1,7 +1,8 @@ -import { compile } from "react-native-css/compiler"; +import { compile, type CompilerOptions } from "react-native-css/compiler"; import type { StyleRule } from "../../compiler/compiler.types"; import { + compilerVariablePrefix, pseudoElementFieldPolicy, scopeRuleToPseudoElement, } from "../../compiler/pseudo-elements"; @@ -14,12 +15,17 @@ interface CompiledClass { /** * Reads the whole rule, not just `d`. A pseudo-element declaration reaches the element through - * any field a declaration can set — `v` carries the --__rn-css-color / --__rn-css-em mirrors - * declarations.ts writes beside `color` and `font-size`, `c` registers a named container, and - * `a` / `dv` make the host animated or variable-driven + * any field a declaration can set — `v` carries authored custom properties alongside the + * --__rn-css-color / --__rn-css-em mirrors declarations.ts writes beside `color` and + * `font-size`, `c` registers a named container, and `a` / `dv` make the host animated or + * variable-driven */ -const compileFor = (css: string, className = "a"): CompiledClass => { - const compiled = compile(css); +const compileFor = ( + css: string, + className = "a", + options: CompilerOptions = {}, +): CompiledClass => { + const compiled = compile(css, options); const rules = (compiled .stylesheet() .s?.find(([name]) => name === className)?.[1] ?? []) as StyleRule[]; @@ -136,6 +142,59 @@ describe("::selection", () => { }); }); + test("an authored custom property is dropped and reported", () => { + // A custom property is the one authored declaration that lands in `v` rather than `d`, so + // it is scoped out by the field policy rather than by the declaration loop, and the report + // has to reach it there. `inlineVariables: false` is the configuration the VariableContext + // section of the README asks for, which is where a dropped custom property costs the most + expect( + compileFor( + `.a::selection { background-color: #ff0000; --brand: blue; }`, + "a", + { inlineVariables: false }, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["--brand"] } }, + }); + }); + + test("an authored custom property is dropped with the optimization left on", () => { + // Declared twice, so the inline-variables optimization keeps it rather than folding it + // into its single use. The drop is the pseudo-element's, not the optimization's + expect( + compileFor( + `.a::selection { background-color: #ff0000; --brand: blue; } .b { --brand: green; }`, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["--brand"] } }, + }); + }); + + test("a custom property the optimization inlines away is not reported", () => { + // Declared once, so inlining folds it into its uses and deletes the declaration before any + // rule is built. Nothing reached the pseudo-element, so nothing was dropped by it — the + // same thing happens to a custom property on a plain rule + expect( + compileFor(`.a::selection { background-color: #ff0000; --brand: blue; }`), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: {}, + }); + }); + + test("an authored custom property alone leaves no rule and still reports", () => { + expect( + compileFor(`.a::selection { --brand: blue; }`, "a", { + inlineVariables: false, + }), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["--brand"] } }, + }); + }); + test("container-name: none registers no container and reports no drop", () => { // `none` empties `c` rather than leaving it absent, so a report guarded on the field // rather than on its entries would warn about a container that was never registered @@ -318,6 +377,52 @@ describe("::placeholder", () => { warnings: { values: { "::placeholder": ["backgroundColor"] } }, }); }); + + test("an authored custom property is dropped and reported", () => { + expect( + compileFor(`.a::placeholder { color: #ff0000; --brand: blue; }`, "a", { + inlineVariables: false, + }), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["placeholderTextColor"]]] }], + warnings: { values: { "::placeholder": ["--brand"] } }, + }); + }); +}); + +describe("the compiler's own custom properties", () => { + test("every custom property the compiler mints carries the prefix the report filters on", () => { + // The report tells a mirror from an authored name by this prefix, and the mirrors are + // minted over in declarations.ts. Reading the names off a compiled rule ties the two ends + // together: renaming the namespace at either end turns this red rather than leaving the + // report to name the compiler's own variables on every pseudo-element rule that sets one + const minted = compileFor( + `.a { color: #ff0000; font-size: 40px; direction: rtl; }`, + ) + .rules.flatMap((rule) => rule.v ?? []) + .map(([name]) => name); + + // Without this the loop below would assert nothing if the mirrors ever stopped being minted + expect(minted.length).toBeGreaterThan(0); + + expect( + minted.filter((name) => !name.startsWith(compilerVariablePrefix)), + ).toStrictEqual([]); + }); + + test("a mirror stays silent and the authored property beside it is reported", () => { + // `color` writes both: a `d` entry, reported as `color`, and a --__rn-css-color mirror the + // compiler minted itself. `--brand` is the only custom property the user wrote, so it is + // the only one worth naming — reporting the mirror too would add noise to every rule + expect( + compileFor(`.a::selection { color: #ff0000; --brand: blue; }`, "a", { + inlineVariables: false, + }), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["color", "--brand"] } }, + }); + }); }); describe("rules without a pseudo-element", () => { diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx index f8bac64d..43ba11bd 100644 --- a/src/__tests__/native/pseudo-elements.test.tsx +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -98,6 +98,33 @@ test("::selection { container-name } does not turn the host into a container", ( ); }); +test("::selection { --custom } does not publish the variable to the subtree", () => { + // A custom property is the one authored declaration that lands in `v` rather than `d`, and + // `v` is the host's variable scope: carried over, every descendant would read it. The + // compiler reports the drop, but nothing carries a compiler warning into the runtime, so + // this is the whole of what the native side can observe + registerCSS( + ` + .a::selection { background-color: #ff0000; --brand: #00ff00; } + .child { background-color: var(--brand); } + `, + { inlineVariables: false }, + ); + + render( + + + + + + , + ); + + expect(propsWithoutTestID(testID)).toStrictEqual( + propsWithoutTestID(controlTestID), + ); +}); + test("::selection { background-color } still reaches selectionColor", () => { registerCSS(`.a::selection { background-color: #ff0000; }`); diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d9e089f0..4ca7b8a6 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -15,6 +15,13 @@ const pseudoElementProp = { export type PseudoElement = keyof typeof pseudoElementProp; +/** + * The namespace the compiler mints its own custom properties in. `color` and `font-size` + * mirror into `--__rn-css-color` / `--__rn-css-em` so the runtime can resolve currentColor and + * em, and `direction` into `--__rn-css-direction` + */ +export const compilerVariablePrefix = "__rn-css-"; + const pseudoElements: PseudoElement[] = Object.keys(pseudoElementProp).filter( (key): key is PseudoElement => key in pseudoElementProp, ); @@ -82,13 +89,19 @@ export function scopeRuleToPseudoElement( scopeDeclaration(declaration, from, to, declarations, dropped); } - // `c` and `v` are the fields an authored declaration reaches without passing through `d`. - // Every `c` entry comes from container-name, container-type or the container shorthand, so - // the report names the family rather than picking one of the three. `v` is not reported at - // all: it holds the compiler's own --__rn-css-* mirrors of a `d` declaration already - // reported here alongside any authored custom property, so a `--x` written inside a - // pseudo-element is dropped silently. `a` is only ever set beside the `d` entry that set - // it, so it is already reported through that entry + // `v` and `c` are the fields an authored declaration reaches without passing through `d`. + // A `v` entry is reported under the name it was written with, minus the compiler's own + // mirrors: each of those sits beside a `d` declaration the loop above already reported, so + // naming them would add a variable the user never wrote to every rule that sets a colour or + // a font size. Every `c` entry comes from container-name, container-type or the container + // shorthand, so the report names the family rather than picking one of the three. `a` is + // only ever set beside the `d` entry that set it, so it is already reported through that + for (const [name] of rule.v ?? []) { + if (!name.startsWith(compilerVariablePrefix)) { + dropped.push(`--${name}`); + } + } + if (rule.c?.length) { dropped.push("container"); }