From 0fba611b6635b3b1f38700a327bd0d4e15eff8ca Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 12:24:14 +0300 Subject: [PATCH 1/6] fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element A pseudo-element rule mapped one declaration onto a React Native prop and returned the rest unchanged, so every other declaration styled the host: `::selection { background-color }` painted the whole control, a `color` published `currentColor` to the subtree, a `font-size` became the host's em base, `container-name` made the host a container and an animation made it animated. The rule is now rebuilt from what the pseudo-element can express: the mapped declaration under the selector's own conditions. Every StyleRule field is classified by a policy typed over `keyof StyleRule`, so a new field fails to compile until it is. Dropped declarations are reported through `compile().warnings()` under `::selection` / `::placeholder`. `::selection` maps `background-color` to `selectionColor`: in CSS `color` there is the selected text, while `selectionColor` is the band behind it. BREAKING CHANGE: `::selection { color }` no longer sets `selectionColor`; use `background-color` (Tailwind `selection:bg-*`). Declarations a pseudo-element cannot express are dropped instead of styling the host, a rule left with none is not registered, and `compile().warnings()` reports them. --- README.md | 45 ++ .../pseudo-element-field-policy.test.ts | 75 +++ .../compiler/pseudo-elements.test.ts | 426 ++++++++++++++++++ src/__tests__/native/pseudo-elements.test.tsx | 183 ++++++++ src/__tests__/vendor/tailwind/states.test.tsx | 21 +- src/compiler/pseudo-elements.ts | 181 ++++++-- src/compiler/selector-builder.ts | 6 +- src/compiler/stylesheet.ts | 63 +-- src/utilities/style-descriptor.ts | 34 ++ 9 files changed, 956 insertions(+), 78 deletions(-) create mode 100644 src/__tests__/compiler/pseudo-element-field-policy.test.ts create mode 100644 src/__tests__/compiler/pseudo-elements.test.ts create mode 100644 src/__tests__/native/pseudo-elements.test.tsx diff --git a/README.md b/README.md index bafb2302..7f3a554c 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,51 @@ 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: + +```css +.input::selection { + background-color: red; /* → selectionColor */ + color: white; /* dropped */ + width: 10px; /* dropped */ +} +``` + +The compiler records each drop, but nothing in the Metro pipeline reads that record — an `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"] } } +``` + +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. + +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-element-field-policy.test.ts b/src/__tests__/compiler/pseudo-element-field-policy.test.ts new file mode 100644 index 00000000..197604be --- /dev/null +++ b/src/__tests__/compiler/pseudo-element-field-policy.test.ts @@ -0,0 +1,75 @@ +import { compile } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; +import { + compilerVariablePrefix, + pseudoElementFieldPolicy, + scopeRuleToPseudoElement, +} from "../../compiler/pseudo-elements"; + +test("every custom property the compiler mints carries the prefix the report filters on", () => { + // Renaming the namespace at either end turns this red instead of leaving the report to name + // the compiler's own variables on every pseudo-element rule that sets one + const minted = ( + compile(`.a { color: #ff0000; font-size: 40px; direction: rtl; }`) + .stylesheet() + .s?.find(([name]) => name === "a")?.[1] ?? [] + ) + .flatMap((rule) => rule.v ?? []) + .map(([name]) => name); + + expect(minted.length).toBeGreaterThan(0); + expect( + minted.filter((name) => !name.startsWith(compilerVariablePrefix)), + ).toStrictEqual([]); +}); + +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", () => { + 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", () => { + 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(); + + 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": + break; + } + } + }); +}); diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts new file mode 100644 index 00000000..8e856a6c --- /dev/null +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -0,0 +1,426 @@ +import { compile, type CompilerOptions } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; + +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 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", + options: CompilerOptions = {}, +): CompiledClass => { + const compiled = compile(css, options); + 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(), + }; +}; + +describe("::selection", () => { + 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"] } }, + }); + }); + + 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 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"] } }, + }); + }); + + 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", () => { + 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).toStrictEqual([]); + expect(warnings.values?.["::selection"]).toContain("animationName"); + }); + + 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.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; ${declaration}; }`, + ), + ).toStrictEqual({ + rules: [{ d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["container"] } }, + }); + }); + + 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 + 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: {}, + }); + }); + + 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: {}, + }); + }); + + 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("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"] } }); + }); + + 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( + `.input::selection { background-color: red; color: white; width: 10px; }`, + "input", + ).warnings, + ).toStrictEqual({ values: { "::selection": ["color", "width"] } }); + }); +}); + +describe("::placeholder", () => { + 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: {}, + }); + }); + + test("an unmapped declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: #ff0000; }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::placeholder": ["backgroundColor"] } }, + }); + }); + + test("an unmapped var() declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: var(--x); }`), + ).toStrictEqual({ + rules: [], + 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("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", () => { + 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" }] }, + ]); + }); +}); diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx new file mode 100644 index 00000000..43ba11bd --- /dev/null +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -0,0 +1,183 @@ +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 { --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; }`); + + 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 } 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(` + .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 c4abd1e0..33ad51f0 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -65,8 +65,10 @@ test("mixed", async () => { expect(component).toHaveStyle({ color: "#fff" }); }); +// 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(); + await render(); const component = screen.getByTestId(testID); expect(component.props).toEqual({ @@ -77,6 +79,23 @@ test("selection", async () => { }); }); +test("selection: an unmappable declaration does not reach the element", async () => { + // 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( + , + ); + + expect(screen.getByTestId(testID).props).toEqual({ + testID, + children: undefined, + }); + + expect(warnings()).toStrictEqual({ + values: { "::selection": ["color"] }, + }); +}); + test("ltr:", async () => { await render(); diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d379da8a..636e9735 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,54 +1,171 @@ -import { isStyleFunction } from "../utilities"; +import { postProcessStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; -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; + +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-"; - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "selectionColor"); - }); +/** + * 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" +>; - return rule; +export interface ScopedRule { + /** The rule to register, or undefined when no declaration survived the scoping */ + rule: StyleRule | undefined; + /** What the pseudo-element cannot express, in declaration order */ + dropped: string[]; } -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); + } + + // `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"); } - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); - }); + if (!declarations.length) { + return { rule: undefined, dropped }; + } + + const scoped: StyleRule = { s: rule.s, d: declarations }; - return rule; + 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 }; } -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 property = toPropertyName(declaration[1]); + + if (property !== from) { + dropped.push(property); + return; } - } else if (typeof declaration === "object") { - const { color: selectionColor, ...rest } = declaration; - if (selectionColor) { - return [rest, [selectionColor, [to]]] as StyleDeclaration[]; + declarations.push( + declaration.length === 3 + ? [declaration[0], [to], declaration[2]] + : [declaration[0], [to]], + ); + + return; + } + + for (const [property, value] of Object.entries(declaration)) { + if (property === from) { + declarations.push([value, [to]]); + } else { + dropped.push(property); } } +} + +/** + * 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; + }, ""); +} - return [declaration]; +function usesVariables(declaration: StyleDeclaration): boolean { + return ( + Array.isArray(declaration) && postProcessStyleFunction(declaration[0])[1] + ); } diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..e3208a19 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -10,6 +10,7 @@ import type { PseudoClassesQuery, SpecificityArray, } from "./compiler.types"; +import type { PseudoElement } from "./pseudo-elements"; interface ReactNativeClassNameSelector { type: "className"; @@ -19,7 +20,7 @@ interface ReactNativeClassNameSelector { containerQuery?: ContainerQuery[]; pseudoClassesQuery?: PseudoClassesQuery; attributeQuery?: AttributeQuery[]; - pseudoElementQuery?: string[]; + pseudoElement?: PseudoElement; } interface ReactNativeGlobalSelector { @@ -133,8 +134,7 @@ function parseComponents( case "placeholder": { specificity[Specificity.PseudoElements] = (specificity[Specificity.PseudoElements] ?? 0) + 1; - root.pseudoElementQuery ??= []; - root.pseudoElementQuery.push(component.kind); + root.pseudoElement = component.kind; return parseComponents(rest, options, root, ref, specificity); } default: { diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..8a31a602 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,8 @@ import type { VariableValue, } from "./compiler.types"; import { - modifyRuleForPlaceholder, - modifyRuleForSelection, + scopeRuleToPseudoElement, + type PseudoElement, } from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; @@ -447,16 +447,29 @@ 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); + if (selector.type === "className" && selector.pseudoElement) { + const pseudoElement = selector.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; } if (!rule) { @@ -617,40 +630,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 7a166fa1..e26bd9d1 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -17,3 +17,37 @@ export function isStyleFunction(value: unknown): value is StyleFunction { typeof value[1] === "string" ); } + +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 e1a053460b0b30616ffd0836a4e60cfccbd03423 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 12:41:21 +0300 Subject: [PATCH 2/6] fix: give a pseudo-element its own custom properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS Pseudo-Elements 4 §3.5: the custom properties a pseudo-element declares are its own, and every other one comes from its originating element. Dropping them left `::selection { --x: red; background-color: var(--x) }` with no selection color, and with inlining on a declared-once property was substituted into every element that reads it. A scoped rule now keeps its variables and carries the pseudo-element it styles (`pe`). The runtime gives each pseudo-element a scope that shadows the element's variables and falls back to them, and never publishes it to the element's children. The inliner leaves a property declared under a pseudo-element alone, so the result no longer depends on `inlineVariables`. `currentcolor` inside a pseudo-element reads its own `color`, as CSS Color 4 specifies. A light-dark() extra rule replaces the rule's declarations after the scoping, so its dark value reached the element; it is scoped too. --- README.md | 45 +- .../pseudo-element-field-policy.test.ts | 108 ++-- .../compiler/pseudo-elements.test.ts | 264 +++++----- src/__tests__/native/pseudo-elements.test.tsx | 468 ++++++++++++------ src/__tests__/vendor/tailwind/states.test.tsx | 27 +- src/compiler/compiler.ts | 23 + src/compiler/compiler.types.ts | 4 + src/compiler/pseudo-elements.ts | 86 ++-- src/compiler/selector-builder.ts | 2 +- src/compiler/stylesheet.ts | 29 +- src/native/react/rules.ts | 7 +- src/native/styles/calculate-props.ts | 12 +- 12 files changed, 616 insertions(+), 459 deletions(-) diff --git a/README.md b/README.md index 7f3a554c..0b6f501c 100644 --- a/README.md +++ b/README.md @@ -237,48 +237,25 @@ This API only allows for setting CSS variables as primitive values. For more com ## 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: +React Native has no pseudo-elements. A `TextInput` has one prop for each of `::selection` and `::placeholder`: -| CSS | React Native prop | On a | -| ---------------------------------- | ---------------------- | ----------- | -| `::selection { background-color }` | `selectionColor` | `TextInput` | -| `::placeholder { color }` | `placeholderTextColor` | `TextInput` | +| CSS | React Native prop | +| ---------------------------------- | ---------------------- | +| `::selection { background-color }` | `selectionColor` | +| `::placeholder { color }` | `placeholderTextColor` | -`::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. +`selectionColor` is the band behind the selected text, which CSS styles with `background-color`; `::selection { color }` is the selected text itself, which React Native cannot style. With Tailwind, use `selection:bg-*` on the input. -Every other declaration inside a pseudo-element is dropped: - -```css -.input::selection { - background-color: red; /* → selectionColor */ - color: white; /* dropped */ - width: 10px; /* dropped */ -} -``` - -The compiler records each drop, but nothing in the Metro pipeline reads that record — an `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 })`. +The custom properties a pseudo-element declares are its own, and `currentcolor` inside it is its own `color`, as CSS specifies. Every other declaration is dropped rather than applied to the element, and `compile()` lists what it dropped. A Metro build does not print this list. ```js -compile(css).warnings(); +compile( + `.input::selection { background-color: red; color: white; width: 10px; }`, +).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. - -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. - -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. +On web the stylesheet reaches the browser unchanged, so both pseudo-elements behave as CSS specifies. ## Optimizations diff --git a/src/__tests__/compiler/pseudo-element-field-policy.test.ts b/src/__tests__/compiler/pseudo-element-field-policy.test.ts index 197604be..2d77030c 100644 --- a/src/__tests__/compiler/pseudo-element-field-policy.test.ts +++ b/src/__tests__/compiler/pseudo-element-field-policy.test.ts @@ -1,75 +1,53 @@ -import { compile } from "react-native-css/compiler"; - import type { StyleRule } from "../../compiler/compiler.types"; import { - compilerVariablePrefix, pseudoElementFieldPolicy, scopeRuleToPseudoElement, } from "../../compiler/pseudo-elements"; -test("every custom property the compiler mints carries the prefix the report filters on", () => { - // Renaming the namespace at either end turns this red instead of leaving the report to name - // the compiler's own variables on every pseudo-element rule that sets one - const minted = ( - compile(`.a { color: #ff0000; font-size: 40px; direction: rtl; }`) - .stylesheet() - .s?.find(([name]) => name === "a")?.[1] ?? [] - ) - .flatMap((rule) => rule.v ?? []) - .map(([name]) => name); - - expect(minted.length).toBeGreaterThan(0); - expect( - minted.filter((name) => !name.startsWith(compilerVariablePrefix)), - ).toStrictEqual([]); +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", () => { + for (const kind of ["carried", "rebuilt", "dropped"] as const) { + expect( + policyFields.filter((field) => pseudoElementFieldPolicy[field] === kind), + ).not.toStrictEqual([]); + } }); -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", () => { - 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", () => { - 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(); - - 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": - break; - } +test("a carried field is kept, a rebuilt one is the pseudo-element's and a dropped one is gone", () => { + const populated: StyleRule = { + s: [1, 1], + d: [["#f00", "backgroundColor"]], + v: [["brand", "#f00"]], + c: ["c:foo"], + pe: "placeholder", + 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(); + + for (const field of policyFields) { + switch (pseudoElementFieldPolicy[field]) { + case "carried": + expect(scoped).toHaveProperty(field, populated[field]); + break; + case "dropped": + expect(scoped).not.toHaveProperty(field); + break; + case "rebuilt": + expect(scoped?.[field]).not.toStrictEqual(populated[field]); + break; } - }); + } }); diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index 8e856a6c..e8f811fd 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -8,13 +8,6 @@ interface CompiledClass { 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 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", @@ -40,26 +33,21 @@ describe("::selection", () => { expect( compileFor(`.a::selection { background-color: #ff0000; }`), ).toStrictEqual({ - rules: [{ d: [["#f00", ["selectionColor"]]] }], + rules: [{ pe: "selection", 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 + test("color is dropped and stays the pseudo-element's currentcolor", () => { expect(compileFor(`.a::selection { color: #ff0000; }`)).toStrictEqual({ - rules: [], + rules: [{ pe: "selection", v: [["__rn-css-color", "#f00"]] }], warnings: { values: { "::selection": ["color"] } }, }); }); - 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 + test("font-size is dropped and its em stays with the pseudo-element", () => { expect(compileFor(`.a::selection { font-size: 40px; }`)).toStrictEqual({ - rules: [], + rules: [{ pe: "selection", v: [["__rn-css-em", 40]] }], warnings: { values: { "::selection": ["fontSize"] } }, }); }); @@ -74,8 +62,6 @@ describe("::selection", () => { }); 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"] } }, @@ -95,14 +81,12 @@ describe("::selection", () => { expect( compileFor(`.a::selection { background-color: #ff0000; width: 10px; }`), ).toStrictEqual({ - rules: [{ d: [["#f00", ["selectionColor"]]] }], + rules: [{ pe: "selection", 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; }`, ); @@ -116,7 +100,9 @@ describe("::selection", () => { `.a::selection { background-color: #ff0000; transition: background-color 1s; }`, ); - expect(rules).toStrictEqual([{ d: [["#f00", ["selectionColor"]]] }]); + expect(rules).toStrictEqual([ + { pe: "selection", d: [["#f00", ["selectionColor"]]] }, + ]); expect(warnings.values?.["::selection"]).toContain("transitionProperty"); }); @@ -125,101 +111,40 @@ describe("::selection", () => { "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; ${declaration}; }`, ), ).toStrictEqual({ - rules: [{ d: [["#f00", ["selectionColor"]]] }], + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], warnings: { values: { "::selection": ["container"] } }, }); }); - 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 + // `none` empties `c` rather than leaving it absent expect( compileFor( `.a::selection { background-color: #ff0000; container-name: none; }`, ), ).toStrictEqual({ - rules: [{ d: [["#f00", ["selectionColor"]]] }], + rules: [{ pe: "selection", 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"]]] }], + rules: [{ pe: "selection", 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({ @@ -249,14 +174,13 @@ describe("::selection", () => { }); 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 + // `em` delays a declaration without making it read a variable expect( compileFor(`.a::selection { background-color: hsl(calc(1em) 50% 50%); }`), ).toStrictEqual({ rules: [ { + pe: "selection", d: [ [ [{}, "hsl", [[{}, "calc", [[{}, "em", 1, 1]]], "50%", "50%"]], @@ -271,12 +195,34 @@ describe("::selection", () => { }); 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 }], + rules: [ + { + pe: "selection", + d: [[[{}, "var", "x", 1], ["selectionColor"], 1]], + dv: 1, + }, + ], + warnings: {}, + }); + }); + + test("light-dark() scopes the dark value as well", () => { + expect( + compileFor( + `.a::selection { background-color: light-dark(#123456, #654321); }`, + ), + ).toStrictEqual({ + rules: [ + { pe: "selection", d: [["#123456", ["selectionColor"]]] }, + { + pe: "selection", + d: [["#654321", ["selectionColor"]]], + m: [["=", "prefers-color-scheme", "dark"]], + }, + ], warnings: {}, }); }); @@ -289,6 +235,7 @@ describe("::selection", () => { ).toStrictEqual({ rules: [ { + pe: "selection", d: [["#f00", ["selectionColor"]]], m: [[">=", "width", 100]], p: { h: 1 }, @@ -307,6 +254,7 @@ describe("::selection", () => { ).toStrictEqual({ rules: [ { + pe: "selection", d: [["#f00", ["selectionColor"]]], cq: [{ m: [">=", "width", 100] }], }, @@ -324,8 +272,6 @@ describe("::selection", () => { }); 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; }`, ); @@ -346,11 +292,15 @@ describe("::selection", () => { }); describe("::placeholder", () => { - 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 + test("color maps to placeholderTextColor and stays its currentcolor", () => { expect(compileFor(`.a::placeholder { color: #ff0000; }`)).toStrictEqual({ - rules: [{ d: [["#f00", ["placeholderTextColor"]]] }], + rules: [ + { + pe: "placeholder", + d: [["#f00", ["placeholderTextColor"]]], + v: [["__rn-css-color", "#f00"]], + }, + ], warnings: {}, }); }); @@ -372,55 +322,123 @@ describe("::placeholder", () => { warnings: { values: { "::placeholder": ["backgroundColor"] } }, }); }); +}); - test("an authored custom property is dropped and reported", () => { +// CSS Pseudo-Elements 4 §3.5: the custom properties a pseudo-element sets are its own +describe("a pseudo-element's custom properties", () => { + test("stay with the pseudo-element and are not reported", () => { expect( - compileFor(`.a::placeholder { color: #ff0000; --brand: blue; }`, "a", { + compileFor(`.a::selection { color: #ff0000; --brand: #123456; }`, "a", { inlineVariables: false, }), ).toStrictEqual({ - rules: [{ d: [["#f00", ["placeholderTextColor"]]] }], - warnings: { values: { "::placeholder": ["--brand"] } }, + rules: [ + { + pe: "selection", + v: [ + ["__rn-css-color", "#f00"], + ["brand", "#123456"], + ], + }, + ], + warnings: { values: { "::selection": ["color"] } }, }); }); -}); -describe("the compiler's own custom properties", () => { - 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 + test("keep a rule that declares nothing else", () => { expect( - compileFor(`.a::selection { color: #ff0000; --brand: blue; }`, "a", { + compileFor(`.a::placeholder { --x: #123456; }`, "a", { inlineVariables: false, }), ).toStrictEqual({ - rules: [], - warnings: { values: { "::selection": ["color", "--brand"] } }, + rules: [{ pe: "placeholder", v: [["x", "#123456"]] }], + warnings: {}, }); }); + + test("are not inlined into the pseudo-element's own declaration", () => { + const css = `.a::selection { --x: #123456; background-color: var(--x); }`; + + expect(compileFor(css)).toStrictEqual({ + rules: [ + { + pe: "selection", + d: [[[{}, "var", "x", 1], ["selectionColor"], 1]], + v: [["x", "#123456"]], + dv: 1, + }, + ], + warnings: {}, + }); + expect(compileFor(css)).toStrictEqual( + compileFor(css, "a", { inlineVariables: false }), + ); + }); + + test.each([ + ["declared", `.a::selection { --x: #123456; } .b { color: var(--x); }`], + [ + "declared !important", + `.a::selection { --x: #123456 !important; } .b { color: var(--x); }`, + ], + [ + "declared through nesting", + `.a { &::selection { --x: #123456; } } .b { color: var(--x); }`, + ], + ])("are not inlined into an element when %s", (_name, css) => { + const { rules } = compileFor(css, "b"); + + expect(rules).toStrictEqual( + compileFor(css, "b", { inlineVariables: false }).rules, + ); + expect(rules[0]?.d).toStrictEqual([[[{}, "var", "x", 1], "color", 1]]); + }); + + test("leave an element's custom property to the optimization", () => { + expect( + compileFor( + `.p { --x: #123456; } .a::selection { background-color: var(--x); }`, + ).rules, + ).toStrictEqual([ + { pe: "selection", d: [["#123456", ["selectionColor"]]] }, + ]); + }); }); 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; }`; + test("a plain rule's light-dark() keeps its dark rule as it is", () => { + expect( + compileFor(`.a { background-color: light-dark(#123456, #654321); }`) + .rules, + ).toStrictEqual([ + { d: [{ backgroundColor: "#123456" }] }, + { + d: [{ backgroundColor: "#654321" }], + m: [["=", "prefers-color-scheme", "dark"]], + }, + ]); + }); - expect(compileFor(css, "a").rules).toStrictEqual([ - { d: [["#f00", ["selectionColor"]]] }, + test("the unscoped half of a grouped selector is untouched", () => { + const css = `.a::selection, .b { background-color: #ff0000; --x: #123456; }`; + const options = { inlineVariables: false } as const; + + expect(compileFor(css, "a", options).rules).toStrictEqual([ + { + pe: "selection", + d: [["#f00", ["selectionColor"]]], + v: [["x", "#123456"]], + }, ]); - expect(compileFor(css, "b").rules).toStrictEqual([ - { d: [{ backgroundColor: "#f00" }] }, + expect(compileFor(css, "b", options).rules).toStrictEqual([ + { d: [{ backgroundColor: "#f00" }], v: [["x", "#123456"]] }, ]); }); }); diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx index 43ba11bd..03ec32e4 100644 --- a/src/__tests__/native/pseudo-elements.test.tsx +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -1,183 +1,335 @@ -import { render, screen } from "@testing-library/react-native"; +import { act, render, screen } from "@testing-library/react-native"; +import type { CompilerOptions } from "react-native-css/compiler"; import { TextInput } from "react-native-css/components/TextInput"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; 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" }); -}); +// Prop names first: a leaked container wraps the children in a provider whose full diff exhausts the heap +const expectIndistinguishableFromControl = () => { + const host = propsWithoutTestID(testID); + const control = propsWithoutTestID(controlTestID); -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), - ); -}); + expect(Object.keys(host).sort()).toStrictEqual(Object.keys(control).sort()); + expect(host).toStrictEqual(control); +}; -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), - ); -}); +describe("declarations a pseudo-element cannot express never reach the host", () => { + test("::selection { color } does not publish currentcolor to the subtree", () => { + registerCSS(` + .sel::selection { color: #ff0000; } + .child { color: currentColor; } + `); -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), - ); -}); + render( + + + + + + , + ); -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), - ); -}); + expectIndistinguishableFromControl(); + }); + + test("::selection { font-size } does not become the element's em base", () => { + registerCSS(` + .a::selection { font-size: 40px; } + .a { width: 2em; } + .control { width: 2em; } + `); + + render( + + + + , + ); + + expectIndistinguishableFromControl(); + }); + + test("::selection { animation } does not render the host as animated", () => { + registerCSS(`.a::selection { animation: spin 1s; }`); + + render( + + + + , + ); + + expectIndistinguishableFromControl(); + }); -test("::selection { background-color } still reaches selectionColor", () => { - registerCSS(`.a::selection { background-color: #ff0000; }`); + test("::selection { container-name } does not turn the host into a container", () => { + registerCSS(` + .a::selection { background-color: #ff0000; container-name: foo; } + .control::selection { background-color: #ff0000; } + `); - render(); + render( + + + + , + ); - expect(screen.getByTestId(testID).props).toStrictEqual({ - children: undefined, - selectionColor: "#f00", - style: {}, - testID, + expectIndistinguishableFromControl(); + }); + + test("::placeholder { color } does not publish currentcolor to the subtree", () => { + registerCSS(` + .a::placeholder { color: #ff0000; } + .child { color: currentColor; } + `); + + render( + + + + + + , + ); + + expectIndistinguishableFromControl(); }); }); -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, +describe("the declaration a pseudo-element can express reaches its prop", () => { + test("::selection { background-color } reaches selectionColor", () => { + registerCSS(`.a::selection { background-color: #ff0000; }`); + + render(); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + selectionColor: "#f00", + style: {}, + testID, + }); + }); + + test("::placeholder { color } reaches placeholderTextColor", () => { + registerCSS(`.a::placeholder { color: #ff0000; }`); + + render(); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#f00", + style: {}, + testID, + }); + }); + + test("light-dark() reaches selectionColor in either scheme, never the host", () => { + registerCSS( + `.a::selection { background-color: light-dark(#123456, #654321); }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props).toStrictEqual({ + children: undefined, + selectionColor: "#123456", + style: {}, + testID, + }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props).toStrictEqual({ + children: undefined, + selectionColor: "#654321", + style: {}, + testID, + }); }); }); -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(` - .a::placeholder { color: #ff0000; } - .child { color: currentColor; } - `); - - render( - - - - - - , - ); - - expect(propsWithoutTestID(testID)).toStrictEqual( - propsWithoutTestID(controlTestID), - ); +const pseudoElements = [ + { + selector: "::selection", + property: "background-color", + prop: "selectionColor", + }, + { + selector: "::placeholder", + property: "color", + prop: "placeholderTextColor", + }, +] as const; + +const inlineVariablesModes = [ + ["on", {}], + ["off", { inlineVariables: false }], +] as const satisfies readonly (readonly [string, CompilerOptions])[]; + +// CSS Pseudo-Elements 4 §3.5: a pseudo-element's own custom properties win, the rest come from its originating element +describe.each(pseudoElements)( + "$selector custom properties", + ({ selector, property, prop }) => { + const other = pseudoElements.find((entry) => entry.selector !== selector); + + describe.each(inlineVariablesModes)( + "with inline variables %s", + (_mode, options) => { + const propOf = (css: string): unknown => { + registerCSS(css, options); + + render( + + + , + ); + + return screen.getByTestId(testID).props[prop]; + }; + + test.each([ + [ + "apply to its own declaration", + `.a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "apply across its rules", + `.a${selector} { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "win over the originating element's", + `.a { --x: #654321; } .a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "win over an ancestor's", + `.p { --x: #654321; } .a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "cascade by importance", + `.a${selector} { --x: #123456 !important; } .a${selector} { --x: #654321; ${property}: var(--x); }`, + ], + [ + "fall back to the originating element's", + `.a { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "fall back to an ancestor's", + `.p { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "fall back to the root's", + `:root { --root: #123456; } .a${selector} { ${property}: var(--root); }`, + ], + ])("%s", (_name, css) => { + expect(propOf(css)).toBe("#123456"); + }); + + test("are not inherited from a parent's pseudo-element", () => { + expect( + propOf( + `.p${selector} { --x: #123456; } .a${selector} { ${property}: var(--x, #654321); }`, + ), + ).toBe("#654321"); + }); + + test("follow their media query", () => { + expect( + propOf( + `.a${selector} { --x: #123456; ${property}: var(--x); } @media (prefers-color-scheme: dark) { .a${selector} { --x: #654321; } }`, + ), + ).toBe("#123456"); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId(testID).props[prop]).toBe("#654321"); + }); + + test("never reach the originating element", () => { + registerCSS( + `.a${selector} { --x: #123456; } .a { background-color: var(--x, #654321); }`, + options, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "#654321", + }); + }); + + test("never reach a descendant", () => { + registerCSS( + `.a${selector} { --x: #123456; } .child { background-color: var(--x, #654321); }`, + options, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "#654321", + }); + }); + + test("never reach the element's other pseudo-element", () => { + expect(other).toBeDefined(); + + registerCSS( + `.a${selector} { --x: #123456; } .a${other?.selector} { ${other?.property}: var(--x, #654321); }`, + options, + ); + + render(); + + expect(screen.getByTestId(testID).props[other?.prop ?? prop]).toBe( + "#654321", + ); + }); + }, + ); + }, +); + +// CSS Color 4: currentcolor is the pseudo-element's own color, or its originating element's when it sets none +describe("currentcolor in a pseudo-element", () => { + test.each([ + [ + "is ::selection's own color", + `.a { color: #654321; } .a::selection { color: #123456; background-color: currentcolor; }`, + { selectionColor: "#123456" }, + ], + [ + "is the originating element's color when ::selection sets none", + `.a { color: #123456; } .a::selection { background-color: currentcolor; }`, + { selectionColor: "#123456" }, + ], + [ + "as ::placeholder's color is the originating element's color", + `.a { color: #123456; } .a::placeholder { color: currentcolor; }`, + { placeholderTextColor: "#123456" }, + ], + ])("%s", (_name, css, expected) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props).toMatchObject(expected); + }); }); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index 33ad51f0..ff04dbd8 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -65,8 +65,7 @@ test("mixed", async () => { expect(component).toHaveStyle({ color: "#fff" }); }); -// 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 +// selectionColor is the band behind the selected text, which CSS styles with background-color test("selection", async () => { await render(); @@ -79,9 +78,7 @@ test("selection", async () => { }); }); -test("selection: an unmappable declaration does not reach the element", async () => { - // 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 +test("selection: the selected text color does not reach the element", async () => { const { warnings } = await render( , ); @@ -96,6 +93,26 @@ test("selection: an unmappable declaration does not reach the element", async () }); }); +test.each([ + [ + "its own custom property", + "selection:[--brand:#123456] selection:bg-(--brand)", + ], + ["the element's custom property", "[--brand:#123456] selection:bg-(--brand)"], + [ + "its own color as currentcolor", + "selection:text-[#123456] selection:bg-current", + ], + [ + "the element's color as currentcolor", + "text-[#123456] selection:bg-current", + ], +])("selection: reads %s", async (_name, className) => { + await render(); + + expect(screen.getByTestId(testID).props.selectionColor).toBe("#123456"); +}); + test("ltr:", async () => { await render(); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..e5a1fd48 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -98,8 +98,27 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { options.inlineRem = effectiveRem; } + const pseudoElementVariables = new Set(); + const firstPassVisitor: Visitor = { Rule(rule) { + // A pseudo-element's custom properties are its own, so no other use may inline them + if ( + rule.type === "style" && + rule.value.selectors.some((selector) => + selector.some((part) => part.type === "pseudo-element"), + ) + ) { + const { declarations = [], importantDeclarations = [] } = + rule.value.declarations ?? {}; + + for (const declaration of [...declarations, ...importantDeclarations]) { + if (declaration.property === "custom") { + pseudoElementVariables.add(declaration.value.name); + } + } + } + if ( rule.type === "unknown" && ["cssInterop", "react-native"].includes(rule.value.name) @@ -166,6 +185,10 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } }; firstPassVisitor.StyleSheetExit = (sheet) => { + for (const name of pseudoElementVariables) { + vars.delete(name); + } + return inlineVariables(sheet, vars); }; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..4d522ce1 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -59,6 +59,8 @@ export interface ReactNativeCssStyleSheet { */ export type StyleRuleSet = StyleRule[]; +export type PseudoElement = "selection" | "placeholder"; + export interface StyleRule { /** Specificity */ s: SpecificityArray; @@ -68,6 +70,8 @@ export interface StyleRule { v?: VariableDescriptor[]; /** Named Containers */ c?: string[]; + /** The pseudo-element the rule styles, which owns its declarations and variables */ + pe?: PseudoElement; /** Declarations use variables */ dv?: number; diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index 636e9735..a3e3cd0c 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,64 +1,51 @@ import { postProcessStyleFunction } from "../utilities"; -import type { StyleDeclaration, StyleRule } from "./compiler.types"; +import type { + PseudoElement, + StyleDeclaration, + StyleRule, +} from "./compiler.types"; /** - * 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 + * The one declaration each pseudo-element can express on the host component, and the React + * Native prop it becomes. `::selection { color }` is the selected text, while selectionColor + * is the band behind it, so ::selection maps background-color */ const pseudoElementProp = { selection: ["backgroundColor", "selectionColor"], placeholder: ["color", "placeholderTextColor"], -} as const satisfies Record; - -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-"; +} as const satisfies Record; /** - * 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 + * What scoping does with each StyleRule field: a `carried` field is kept as it is, a `rebuilt` + * field is recomputed for the pseudo-element, and a `dropped` field never reaches the host. + * Typed over every StyleRule field, so a new one fails to compile until it is classified */ export const pseudoElementFieldPolicy = { - s: "selector", - m: "selector", - p: "selector", - cq: "selector", - aq: "selector", + s: "carried", + m: "carried", + p: "carried", + cq: "carried", + aq: "carried", + v: "carried", d: "rebuilt", dv: "rebuilt", - v: "dropped", + pe: "rebuilt", c: "dropped", a: "dropped", target: "dropped", -} as const satisfies Record< - keyof StyleRule, - "selector" | "rebuilt" | "dropped" ->; +} as const satisfies Record; export interface ScopedRule { - /** The rule to register, or undefined when no declaration survived the scoping */ + /** The rule to register, or undefined when the pseudo-element is left with nothing */ rule: StyleRule | undefined; /** What the pseudo-element cannot express, in declaration order */ dropped: string[]; } /** - * 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 + * Rebuild a rule as the pseudo-element's own: the one declaration it can express and the + * variables it declares, under the selector's own conditions. Anything else would style the + * host element */ export function scopeRuleToPseudoElement( rule: StyleRule, @@ -73,29 +60,19 @@ export function scopeRuleToPseudoElement( scopeDeclaration(declaration, from, to, declarations, dropped); } - // `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}`); - } - } - + // container-name, container-type and the container shorthand all record only the name if (rule.c?.length) { dropped.push("container"); } - if (!declarations.length) { + if (!declarations.length && !rule.v?.length) { return { rule: undefined, dropped }; } - const scoped: StyleRule = { s: rule.s, d: declarations }; + const scoped: StyleRule = { s: rule.s, pe: pseudoElement }; + if (declarations.length) scoped.d = declarations; + if (rule.v?.length) scoped.v = rule.v; if (rule.m) scoped.m = rule.m; if (rule.p) scoped.p = rule.p; if (rule.cq) scoped.cq = rule.cq; @@ -142,9 +119,8 @@ 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 + * The React Native property a declaration writes, spelled the way the runtime reads it: a + * leading `&` routes the path to the top level and `[n]` is an index */ function toPropertyName(path: string | string[]): string { if (!Array.isArray(path)) { diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index e3208a19..1a0fd461 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -8,9 +8,9 @@ import type { ContainerQuery, MediaCondition, PseudoClassesQuery, + PseudoElement, SpecificityArray, } from "./compiler.types"; -import type { PseudoElement } from "./pseudo-elements"; interface ReactNativeClassNameSelector { type: "className"; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 8a31a602..88d6488e 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -11,6 +11,7 @@ import type { CompilerOptions, ContainerQuery, MediaCondition, + PseudoElement, ReactNativeCssStyleSheet, StyleDeclaration, StyleDescriptor, @@ -21,10 +22,7 @@ import type { VariableRecord, VariableValue, } from "./compiler.types"; -import { - scopeRuleToPseudoElement, - type PseudoElement, -} from "./pseudo-elements"; +import { scopeRuleToPseudoElement } from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; type BuilderMode = "style" | "media" | "container" | "keyframes"; @@ -452,15 +450,13 @@ export class StylesheetBuilder { 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); + const pseudoElement = + selector.type === "className" ? selector.pseudoElement : undefined; - if (selector.type === "className" && selector.pseudoElement) { - const pseudoElement = selector.pseudoElement; + 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 + // One authored rule reports once, however many selectors it expands to if (!warnedPseudoElements.has(pseudoElement)) { warnedPseudoElements.add(pseudoElement); @@ -553,10 +549,15 @@ export class StylesheetBuilder { const extraRulesArray = extraRules.get(this.rule); if (extraRulesArray) { for (const extraRule of extraRulesArray) { - this.addRuleToRuleSet( - className, - this.createRuleFromPartial(rule, extraRule), - ); + const merged = this.createRuleFromPartial(rule, extraRule); + // An extra rule brings its own declarations, so they are scoped as well + const scopedExtraRule = pseudoElement + ? scopeRuleToPseudoElement(merged, pseudoElement).rule + : merged; + + if (scopedExtraRule) { + this.addRuleToRuleSet(className, scopedExtraRule); + } } } } else { diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..564e5573 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -109,7 +109,10 @@ export function updateRules( // We we need to inject React context if (rule.a) animated = true; - if (rule.v) { + // A pseudo-element's variables are its own and never reach the element's children + const publishesVariables = Boolean(rule.v) && !rule.pe; + + if (publishesVariables) { variables ??= inheritedVariables; } @@ -130,7 +133,7 @@ export function updateRules( continue; } - if (rule.v) { + if (publishesVariables && rule.v) { if (variables === inheritedVariables) { variables = { ...inheritedVariables }; } diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..57ae3ce9 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import type { InlineVariable, + PseudoElement, StyleDeclaration, StyleRule, } from "react-native-css/compiler"; @@ -32,6 +33,8 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + const pseudoElementVariables: Partial> = + {}; for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -43,9 +46,14 @@ export function calculateProps( continue; } + // A pseudo-element's own variables shadow the element's, which it otherwise inherits + const variables: InlineVariable = rule.pe + ? (pseudoElementVariables[rule.pe] ??= Object.create(inlineVariables)) + : inlineVariables; + if (rule.v) { for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; + variables[variable[0]] = variable[1]; } } @@ -68,7 +76,7 @@ export function calculateProps( applyDeclarations( get, rule.d, - inlineVariables, + variables, inheritedVariables, delayedStyles, transformStyles, From 3a6ddd68f3cad488f488c4f6d88812fda9c320b0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 12:45:23 +0300 Subject: [PATCH 3/6] fix(compiler): inline a custom property elements share with pseudo-elements Only a property that pseudo-elements alone declare is theirs. One declared for `*, ::before, ::after` also belongs to every element, so it keeps the inlining it had: excluding it left Tailwind's `--tw-border-style` unresolved in `border-x-*`. --- src/__tests__/compiler/pseudo-elements.test.ts | 17 +++++++++++------ src/compiler/compiler.ts | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index e8f811fd..27b9df75 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -394,12 +394,17 @@ describe("a pseudo-element's custom properties", () => { expect(rules[0]?.d).toStrictEqual([[[{}, "var", "x", 1], "color", 1]]); }); - test("leave an element's custom property to the optimization", () => { - expect( - compileFor( - `.p { --x: #123456; } .a::selection { background-color: var(--x); }`, - ).rules, - ).toStrictEqual([ + test.each([ + [ + "an element's", + `.p { --x: #123456; } .a::selection { background-color: var(--x); }`, + ], + [ + "one elements and pseudo-elements share", + `*, ::before, ::after { --x: #123456; } .a::selection { background-color: var(--x); }`, + ], + ])("leave %s custom property to the optimization", (_name, css) => { + expect(compileFor(css).rules).toStrictEqual([ { pe: "selection", d: [["#123456", ["selectionColor"]]] }, ]); }); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index e5a1fd48..f0030b6b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -102,10 +102,10 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { const firstPassVisitor: Visitor = { Rule(rule) { - // A pseudo-element's custom properties are its own, so no other use may inline them + // Custom properties only pseudo-elements declare are theirs alone, so no other use may inline them if ( rule.type === "style" && - rule.value.selectors.some((selector) => + rule.value.selectors.every((selector) => selector.some((part) => part.type === "pseudo-element"), ) ) { From b93e84edf80888bd927efd8ddc793a0bda9f2524 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:00:47 +0300 Subject: [PATCH 4/6] refactor(native): build a pseudo-element's variable scope explicitly A prototype chain made the element's variables visible to the pseudo-element, which holds only while the resolver reads a scope by property lookup. The scope is now a plain object: the element's variables, then the pseudo-element's own, assigned once every rule has been walked. Every declaration that reads a variable is delayed, so each resolves against the full scope. --- src/native/styles/calculate-props.ts | 46 +++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 57ae3ce9..1bf9667c 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -33,8 +33,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; - const pseudoElementVariables: Partial> = - {}; + const pseudoElementScopes = new Map(); for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -46,14 +45,15 @@ export function calculateProps( continue; } - // A pseudo-element's own variables shadow the element's, which it otherwise inherits - const variables: InlineVariable = rule.pe - ? (pseudoElementVariables[rule.pe] ??= Object.create(inlineVariables)) - : inlineVariables; + const pseudoElementScope = rule.pe + ? readPseudoElementScope(pseudoElementScopes, rule.pe) + : undefined; if (rule.v) { + const declared = pseudoElementScope?.declared ?? inlineVariables; + for (const variable of rule.v) { - variables[variable[0]] = variable[1]; + declared[variable[0]] = variable[1]; } } @@ -76,7 +76,7 @@ export function calculateProps( applyDeclarations( get, rule.d, - variables, + pseudoElementScope?.resolved ?? inlineVariables, inheritedVariables, delayedStyles, transformStyles, @@ -87,6 +87,12 @@ export function calculateProps( } } + // A pseudo-element's own variables shadow the element's, which it otherwise inherits. + // Every declaration that reads a variable is delayed, so it resolves against the full scope. + for (const { declared, resolved } of pseudoElementScopes.values()) { + Object.assign(resolved, inlineVariables, declared); + } + for (const delayedStyle of delayedStyles) { delayedStyle(); } @@ -102,6 +108,30 @@ export function calculateProps( }; } +interface PseudoElementScope { + /** The variables the pseudo-element's own rules declare */ + declared: InlineVariable; + /** What its declarations resolve against: the element's variables, then its own */ + resolved: InlineVariable; +} + +function readPseudoElementScope( + scopes: Map, + pseudoElement: PseudoElement, +): PseudoElementScope { + let scope = scopes.get(pseudoElement); + + if (!scope) { + scope = { + declared: { [VAR_SYMBOL]: "inline" }, + resolved: { [VAR_SYMBOL]: "inline" }, + }; + scopes.set(pseudoElement, scope); + } + + return scope; +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], From fb93c8551f61b6fe2642132c85543e10d85cf348 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:08:43 +0300 Subject: [PATCH 5/6] test(compiler): check a pseudo-element's rule against the element's A mapped declaration now compiles to exactly what the same declaration gives an element, re-targeted at the pseudo-element's prop, with the same variable subscription and the same variables declared. The comparison is derived per value, so it holds however a value is represented. --- .../compiler/pseudo-elements.test.ts | 242 ++++++++++-------- 1 file changed, 131 insertions(+), 111 deletions(-) diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts index 27b9df75..4cf62d1f 100644 --- a/src/__tests__/compiler/pseudo-elements.test.ts +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -28,6 +28,103 @@ const compileFor = ( }; }; +const inlineVariablesOff = { inlineVariables: false } as const; + +/** The value a rule writes to `prop`, whether it compiled to a static object or a dynamic entry */ +const readWrittenValue = ( + rule: Partial | undefined, + prop: string, +): unknown => { + for (const declaration of rule?.d ?? []) { + if (!Array.isArray(declaration)) { + if (Object.hasOwn(declaration, prop)) { + return declaration[prop]; + } + continue; + } + + const path = declaration[1]; + + if (path === prop || (Array.isArray(path) && path.join() === prop)) { + return declaration[0]; + } + } + + return undefined; +}; + +const readsCustomProperty = (value: unknown, name: string): boolean => + Array.isArray(value) && + ((value[1] === "var" && + (value[2] === name || (Array.isArray(value[2]) && value[2][0] === name))) || + value.some((entry) => readsCustomProperty(entry, name))); + +const pseudoElements = [ + { + selector: "::selection", + property: "background-color", + elementProp: "backgroundColor", + prop: "selectionColor", + pseudoElement: "selection", + }, + { + selector: "::placeholder", + property: "color", + elementProp: "color", + prop: "placeholderTextColor", + pseudoElement: "placeholder", + }, +] as const; + +// The pseudo-element's rule is the element's rule for the same CSS, re-targeted at its prop +describe.each(pseudoElements)( + "$selector compiles what an element would", + ({ selector, property, elementProp, prop, pseudoElement }) => { + test.each([ + "#ff0000", + "var(--x)", + "hsl(calc(1em) 50% 50%)", + "color-mix(in srgb, #ff0000 50%, transparent)", + "currentcolor", + ])(`${property}: %s writes the element's value to ${prop}`, (value) => { + const [scoped] = compileFor( + `.a${selector} { ${property}: ${value}; }`, + "a", + inlineVariablesOff, + ).rules; + const [element] = compileFor( + `.b { ${property}: ${value}; }`, + "b", + inlineVariablesOff, + ).rules; + + expect(scoped?.pe).toBe(pseudoElement); + expect(readWrittenValue(element, elementProp)).toBeDefined(); + expect(readWrittenValue(scoped, prop)).toStrictEqual( + readWrittenValue(element, elementProp), + ); + expect(scoped?.dv).toBe(element?.dv); + }); + + test("declares the variables an element would", () => { + const declarations = `color: #ff0000; font-size: 40px; --brand: #123456;`; + const [scoped] = compileFor( + `.a${selector} { ${declarations} }`, + "a", + inlineVariablesOff, + ).rules; + const [element] = compileFor( + `.b { ${declarations} }`, + "b", + inlineVariablesOff, + ).rules; + + expect(element?.v?.length).toBeGreaterThan(0); + expect(scoped?.v).toStrictEqual(element?.v); + }); + }, +); + describe("::selection", () => { test("background-color maps to selectionColor and sets nothing else", () => { expect( @@ -38,18 +135,14 @@ describe("::selection", () => { }); }); - test("color is dropped and stays the pseudo-element's currentcolor", () => { - expect(compileFor(`.a::selection { color: #ff0000; }`)).toStrictEqual({ - rules: [{ pe: "selection", v: [["__rn-css-color", "#f00"]] }], - warnings: { values: { "::selection": ["color"] } }, - }); - }); + test.each([ + ["color: #ff0000", "color"], + ["font-size: 40px", "fontSize"], + ])("`%s` writes no prop and is reported", (declaration, reported) => { + const { rules, warnings } = compileFor(`.a::selection { ${declaration}; }`); - test("font-size is dropped and its em stays with the pseudo-element", () => { - expect(compileFor(`.a::selection { font-size: 40px; }`)).toStrictEqual({ - rules: [{ pe: "selection", v: [["__rn-css-em", 40]] }], - warnings: { values: { "::selection": ["fontSize"] } }, - }); + expect(rules.map((rule) => rule.d)).toStrictEqual([undefined]); + expect(warnings).toStrictEqual({ values: { "::selection": [reported] } }); }); test("an unmapped static declaration is dropped", () => { @@ -173,42 +266,6 @@ describe("::selection", () => { }); }); - test("a delayed declaration that reads no variable does not set dv", () => { - // `em` delays a declaration without making it read a variable - expect( - compileFor(`.a::selection { background-color: hsl(calc(1em) 50% 50%); }`), - ).toStrictEqual({ - rules: [ - { - pe: "selection", - d: [ - [ - [{}, "hsl", [[{}, "calc", [[{}, "em", 1, 1]]], "50%", "50%"]], - ["selectionColor"], - 1, - ], - ], - }, - ], - warnings: {}, - }); - }); - - test("a mapped var() declaration keeps its variable subscription", () => { - expect( - compileFor(`.a::selection { background-color: var(--x); }`), - ).toStrictEqual({ - rules: [ - { - pe: "selection", - d: [[[{}, "var", "x", 1], ["selectionColor"], 1]], - dv: 1, - }, - ], - warnings: {}, - }); - }); - test("light-dark() scopes the dark value as well", () => { expect( compileFor( @@ -292,19 +349,6 @@ describe("::selection", () => { }); describe("::placeholder", () => { - test("color maps to placeholderTextColor and stays its currentcolor", () => { - expect(compileFor(`.a::placeholder { color: #ff0000; }`)).toStrictEqual({ - rules: [ - { - pe: "placeholder", - d: [["#f00", ["placeholderTextColor"]]], - v: [["__rn-css-color", "#f00"]], - }, - ], - warnings: {}, - }); - }); - test("an unmapped declaration is dropped", () => { expect( compileFor(`.a::placeholder { background-color: #ff0000; }`), @@ -326,30 +370,19 @@ describe("::placeholder", () => { // CSS Pseudo-Elements 4 §3.5: the custom properties a pseudo-element sets are its own describe("a pseudo-element's custom properties", () => { - test("stay with the pseudo-element and are not reported", () => { + test("are not reported", () => { expect( - compileFor(`.a::selection { color: #ff0000; --brand: #123456; }`, "a", { - inlineVariables: false, - }), - ).toStrictEqual({ - rules: [ - { - pe: "selection", - v: [ - ["__rn-css-color", "#f00"], - ["brand", "#123456"], - ], - }, - ], - warnings: { values: { "::selection": ["color"] } }, - }); + compileFor( + `.a::selection { color: #ff0000; --brand: #123456; }`, + "a", + inlineVariablesOff, + ).warnings, + ).toStrictEqual({ values: { "::selection": ["color"] } }); }); test("keep a rule that declares nothing else", () => { expect( - compileFor(`.a::placeholder { --x: #123456; }`, "a", { - inlineVariables: false, - }), + compileFor(`.a::placeholder { --x: #123456; }`, "a", inlineVariablesOff), ).toStrictEqual({ rules: [{ pe: "placeholder", v: [["x", "#123456"]] }], warnings: {}, @@ -358,21 +391,10 @@ describe("a pseudo-element's custom properties", () => { test("are not inlined into the pseudo-element's own declaration", () => { const css = `.a::selection { --x: #123456; background-color: var(--x); }`; + const { rules } = compileFor(css); - expect(compileFor(css)).toStrictEqual({ - rules: [ - { - pe: "selection", - d: [[[{}, "var", "x", 1], ["selectionColor"], 1]], - v: [["x", "#123456"]], - dv: 1, - }, - ], - warnings: {}, - }); - expect(compileFor(css)).toStrictEqual( - compileFor(css, "a", { inlineVariables: false }), - ); + expect(rules).toStrictEqual(compileFor(css, "a", inlineVariablesOff).rules); + expect(readsCustomProperty(rules[0]?.d, "x")).toBe(true); }); test.each([ @@ -388,34 +410,33 @@ describe("a pseudo-element's custom properties", () => { ])("are not inlined into an element when %s", (_name, css) => { const { rules } = compileFor(css, "b"); - expect(rules).toStrictEqual( - compileFor(css, "b", { inlineVariables: false }).rules, - ); - expect(rules[0]?.d).toStrictEqual([[[{}, "var", "x", 1], "color", 1]]); + expect(rules).toStrictEqual(compileFor(css, "b", inlineVariablesOff).rules); + expect(readsCustomProperty(rules[0]?.d, "x")).toBe(true); }); test.each([ [ - "an element's", - `.p { --x: #123456; } .a::selection { background-color: var(--x); }`, + ":root's", + `:root { --x: #123456; } .a::selection { background-color: var(--x); }`, ], [ - "one elements and pseudo-elements share", + "the one elements and pseudo-elements share", `*, ::before, ::after { --x: #123456; } .a::selection { background-color: var(--x); }`, ], ])("leave %s custom property to the optimization", (_name, css) => { - expect(compileFor(css).rules).toStrictEqual([ - { pe: "selection", d: [["#123456", ["selectionColor"]]] }, - ]); + expect(readWrittenValue(compileFor(css).rules[0], "selectionColor")).toBe( + "#123456", + ); }); }); describe("rules without a pseudo-element", () => { - test("a plain rule on the same class keeps every field", () => { - expect(compileFor(`.a { color: #ff0000; }`)).toStrictEqual({ - rules: [{ d: [{ color: "#f00" }], v: [["__rn-css-color", "#f00"]] }], - warnings: {}, - }); + test("a plain rule on the same class compiles as it does alone", () => { + expect( + compileFor( + `.a { color: #ff0000; } .a::selection { background-color: #0000ff; }`, + ).rules[0], + ).toStrictEqual(compileFor(`.a { color: #ff0000; }`).rules[0]); }); test("a plain rule's light-dark() keeps its dark rule as it is", () => { @@ -433,16 +454,15 @@ describe("rules without a pseudo-element", () => { test("the unscoped half of a grouped selector is untouched", () => { const css = `.a::selection, .b { background-color: #ff0000; --x: #123456; }`; - const options = { inlineVariables: false } as const; - expect(compileFor(css, "a", options).rules).toStrictEqual([ + expect(compileFor(css, "a", inlineVariablesOff).rules).toStrictEqual([ { pe: "selection", d: [["#f00", ["selectionColor"]]], v: [["x", "#123456"]], }, ]); - expect(compileFor(css, "b", options).rules).toStrictEqual([ + expect(compileFor(css, "b", inlineVariablesOff).rules).toStrictEqual([ { d: [{ backgroundColor: "#f00" }], v: [["x", "#123456"]] }, ]); }); From 76a72820aba0f3d2c696f4a902b015e271b84af5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 15:03:08 +0300 Subject: [PATCH 6/6] test(native): declare color-scheme for the light-dark() selection case A browser resolves light-dark() against the element's used colour scheme, which needs `color-scheme: light dark` to follow the preferred one. Declaring it keeps the case true wherever color-scheme is honoured. --- src/__tests__/native/pseudo-elements.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx index 03ec32e4..7b7c5d0e 100644 --- a/src/__tests__/native/pseudo-elements.test.tsx +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -134,7 +134,7 @@ describe("the declaration a pseudo-element can express reaches its prop", () => test("light-dark() reaches selectionColor in either scheme, never the host", () => { registerCSS( - `.a::selection { background-color: light-dark(#123456, #654321); }`, + `:root { color-scheme: light dark; } .a::selection { background-color: light-dark(#123456, #654321); }`, ); render();