diff --git a/README.md b/README.md index bafb2302..933a0cad 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 — a `expo start` build prints nothing, and the only thing you observe is that the declaration has no effect on native. Two places do read it: `compile()`, and a jest test through `registerCSS(css, { debug: true })`. + +```js +compile(css).warnings(); +// { values: { "::selection": ["color", "width"] } } +``` + +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-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts new file mode 100644 index 00000000..5b6c943a --- /dev/null +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -0,0 +1,504 @@ +import { compile, type CompilerOptions } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; +import { + compilerVariablePrefix, + pseudoElementFieldPolicy, + scopeRuleToPseudoElement, +} from "../../compiler/pseudo-elements"; + +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("every custom property the compiler mints carries the prefix the report filters on", () => { + // The report tells a mirror from an authored name by this prefix, and the mirrors are + // minted over in declarations.ts. Reading the names off a compiled rule ties the two ends + // together: renaming the namespace at either end turns this red rather than leaving the + // report to name the compiler's own variables on every pseudo-element rule that sets one + const minted = compileFor( + `.a { color: #ff0000; font-size: 40px; direction: rtl; }`, + ) + .rules.flatMap((rule) => rule.v ?? []) + .map(([name]) => name); + + // Without this the loop below would assert nothing if the mirrors ever stopped being minted + expect(minted.length).toBeGreaterThan(0); + + expect( + minted.filter((name) => !name.startsWith(compilerVariablePrefix)), + ).toStrictEqual([]); + }); + + test("a mirror stays silent and the authored property beside it is reported", () => { + // `color` writes both: a `d` entry, reported as `color`, and a --__rn-css-color mirror the + // compiler minted itself. `--brand` is the only custom property the user wrote, so it is + // the only one worth naming — reporting the mirror too would add noise to every rule + expect( + compileFor(`.a::selection { color: #ff0000; --brand: blue; }`, "a", { + inlineVariables: false, + }), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["color", "--brand"] } }, + }); + }); +}); + +describe("rules without a pseudo-element", () => { + test("a plain rule on the same class keeps every field", () => { + // Control for an over-broad fix: scoping runs per selector, so a rule that reaches the + // element directly keeps its static object AND the --__rn-css-color mirror + expect(compileFor(`.a { color: #ff0000; }`)).toStrictEqual({ + rules: [{ d: [{ color: "#f00" }], v: [["__rn-css-color", "#f00"]] }], + warnings: {}, + }); + }); + + test("the unscoped half of a grouped selector is untouched", () => { + // Both selectors share one rule object, so scoping the pseudo-element half by mutation + // rather than by rebuilding would strip the plain half too + const css = `.a::selection, .b { background-color: #ff0000; }`; + + expect(compileFor(css, "a").rules).toStrictEqual([ + { d: [["#f00", ["selectionColor"]]] }, + ]); + expect(compileFor(css, "b").rules).toStrictEqual([ + { d: [{ backgroundColor: "#f00" }] }, + ]); + }); +}); + +describe("field policy", () => { + const policyFields = Object.keys(pseudoElementFieldPolicy).filter( + (key): key is keyof typeof pseudoElementFieldPolicy => + key in pseudoElementFieldPolicy, + ); + + test("the policy classifies at least one field of each kind", () => { + // Without this, an empty or single-kind policy would make the table below assert nothing + for (const kind of ["selector", "rebuilt", "dropped"] as const) { + expect( + policyFields.filter( + (field) => pseudoElementFieldPolicy[field] === kind, + ), + ).not.toStrictEqual([]); + } + }); + + test("a selector field is carried over and a dropped field never is", () => { + // Driven off the policy rather than a hand-written list, so classifying a new StyleRule + // field here is what puts it under test + const populated: StyleRule = { + s: [1, 1], + d: [["#f00", "backgroundColor"]], + v: [["__rn-css-color", "#f00"]], + c: ["c:foo"], + dv: 1, + a: true, + target: "style", + m: [[">=", "width", 100]], + p: { h: 1 }, + cq: [{ m: [">=", "width", 100] }], + aq: [["d", "x"]], + }; + + const { rule: scoped } = scopeRuleToPseudoElement(populated, "selection"); + + expect(scoped).toBeDefined(); + + for (const field of policyFields) { + switch (pseudoElementFieldPolicy[field]) { + case "selector": + expect(scoped).toHaveProperty(field, populated[field]); + break; + case "dropped": + expect(scoped).not.toHaveProperty(field); + break; + case "rebuilt": + // Asserted by the behaviour tests above, which pin what each is rebuilt from + break; + } + } + }); +}); diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx new file mode 100644 index 00000000..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..4ca7b8a6 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,54 +1,187 @@ -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-"; + +const pseudoElements: PseudoElement[] = Object.keys(pseudoElementProp).filter( + (key): key is PseudoElement => key in pseudoElementProp, +); - 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" +>; + +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 getPseudoElement( + pseudoElementQuery: string[], +): PseudoElement | undefined { + for (const pseudoElement of pseudoElements) { + if (pseudoElementQuery.includes(pseudoElement)) { + return pseudoElement; + } + } - return rule; + return undefined; } -export function modifyRuleForPlaceholder( +/** + * Rebuild a rule so it carries only what the pseudo-element can express: the one mapped + * declaration, under the selector's own conditions. Every other declaration is the + * pseudo-element's own and would paint the host element if it were carried over + */ +export function scopeRuleToPseudoElement( rule: StyleRule, -): StyleRule | undefined { - if (!rule.d) { - return; + pseudoElement: PseudoElement, +): ScopedRule { + const [from, to] = pseudoElementProp[pseudoElement]; + + const declarations: StyleDeclaration[] = []; + const dropped: string[] = []; + + for (const declaration of rule.d ?? []) { + scopeDeclaration(declaration, from, to, declarations, dropped); } - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); - }); + // `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"); + } + + if (!declarations.length) { + return { rule: undefined, dropped }; + } - return rule; + const scoped: StyleRule = { s: rule.s, d: declarations }; + + if (rule.m) scoped.m = rule.m; + if (rule.p) scoped.p = rule.p; + if (rule.cq) scoped.cq = rule.cq; + if (rule.aq) scoped.aq = rule.aq; + + if (declarations.some(usesVariables)) { + scoped.dv = 1; + } + + return { rule: scoped, dropped }; } -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/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..f5edfce0 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -1,7 +1,7 @@ import type { SelectorList } from "lightningcss"; import { - isStyleDescriptorArray, + postProcessStyleFunction, Specificity, specificityCompareFn, } from "../utilities"; @@ -22,8 +22,9 @@ import type { VariableValue, } from "./compiler.types"; import { - modifyRuleForPlaceholder, - modifyRuleForSelection, + getPseudoElement, + scopeRuleToPseudoElement, + type PseudoElement, } from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; @@ -447,15 +448,31 @@ export class StylesheetBuilder { this.options, ); + const warnedPseudoElements = new Set(); + for (const selector of normalizedSelectors) { // We are going to be apply the current rule to n selectors, so we clone the rule let rule: StyleRule | undefined = this.cloneRule(this.rule); if (selector.type === "className" && selector.pseudoElementQuery) { - if (selector.pseudoElementQuery.includes("selection")) { - rule = modifyRuleForSelection(rule); - } else if (selector.pseudoElementQuery.includes("placeholder")) { - rule = modifyRuleForPlaceholder(rule); + const pseudoElement = getPseudoElement(selector.pseudoElementQuery); + + if (pseudoElement) { + const scoped = scopeRuleToPseudoElement(rule, pseudoElement); + + // A supported property dropped for being in the wrong scope warns like an + // unsupported one, keyed by the pseudo-element it was scoped out of. Every + // selector scopes the same clone, so one authored rule reports once however + // many selectors it expands to + if (!warnedPseudoElements.has(pseudoElement)) { + warnedPseudoElements.add(pseudoElement); + + for (const property of scoped.dropped) { + this.addWarning("style", `::${pseudoElement}`, property); + } + } + + rule = scoped.rule; } } @@ -617,40 +634,6 @@ function isStyleFunction( ); } -function postProcessStyleFunction(value: StyleDescriptor): [ - // Should it be delayed - boolean, - // Does it use variables - boolean, -] { - if (!Array.isArray(value)) { - return [false, false]; - } - - if (isStyleDescriptorArray(value)) { - let shouldDelay = false; - let usesVariables = false; - for (const v of value) { - const [delayed, variables] = postProcessStyleFunction(v); - shouldDelay ||= delayed; - usesVariables ||= variables; - } - - return [shouldDelay, usesVariables]; - } - - let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - - usesVariables ||= value[1] === "var"; - shouldDelay ||= value[3] === 1 || usesVariables; - - if (shouldDelay) { - return [true, usesVariables]; - } - - return [false, false]; -} - function allEqual(...params: unknown[]) { return params.every((param, index, array) => { return index === 0 ? true : equal(array[0], param); diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 1310d62b..c7e789cb 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -22,3 +22,37 @@ export function isStyleFunction( return false; } + +export function postProcessStyleFunction(value: StyleDescriptor): [ + // Should it be delayed + boolean, + // Does it use variables + boolean, +] { + if (!Array.isArray(value)) { + return [false, false]; + } + + if (isStyleDescriptorArray(value)) { + let shouldDelay = false; + let usesVariables = false; + for (const v of value) { + const [delayed, variables] = postProcessStyleFunction(v); + shouldDelay ||= delayed; + usesVariables ||= variables; + } + + return [shouldDelay, usesVariables]; + } + + let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); + + usesVariables ||= value[1] === "var"; + shouldDelay ||= value[3] === 1 || usesVariables; + + if (shouldDelay) { + return [true, usesVariables]; + } + + return [false, false]; +}