diff --git a/src/__tests__/compiler/light-dark.test.ts b/src/__tests__/compiler/light-dark.test.ts new file mode 100644 index 00000000..e5dae72d --- /dev/null +++ b/src/__tests__/compiler/light-dark.test.ts @@ -0,0 +1,450 @@ +import { compile } from "react-native-css/compiler"; +import type { + CompilerOptions, + StyleDeclaration, + StyleDescriptor, + StyleRule, +} from "react-native-css/compiler"; + +/** + * `light-dark()` compiles to two rules: the current one, carrying the light + * branch, and an extra copy of it under `prefers-color-scheme: dark` carrying + * the dark branch. These helpers read the pair back out of a compiled + * stylesheet so a test can state what each half is allowed to contain. + */ +const rulesFor = ( + css: string, + className: string, + options?: CompilerOptions, +): StyleRule[] => { + const ruleSets = compile(css, options).stylesheet().s ?? []; + return ruleSets.flatMap(([name, rules]) => (name === className ? rules : [])); +}; + +const isDarkRule = (rule: StyleRule): boolean => + Boolean( + rule.m?.some( + (condition) => + condition[0] === "=" && + condition[1] === "prefers-color-scheme" && + condition[2] === "dark", + ), + ); + +const darkRules = ( + css: string, + className: string, + options?: CompilerOptions, +): StyleRule[] => rulesFor(css, className, options).filter(isDarkRule); + +const lightRule = ( + css: string, + className: string, + options?: CompilerOptions, +): StyleRule => { + const rule = rulesFor(css, className, options).find( + (candidate) => !isDarkRule(candidate), + ); + + if (!rule) { + throw new Error(`No unconditional rule for .${className}`); + } + + return rule; +}; + +/** + * The style properties a rule sets. A declaration is either a static record of + * property/value pairs or a `[value, propertyPath]` tuple, and both forms + * appear in the same list. + */ +const declaredProperties = (rule: StyleRule): string[] => { + return (rule.d ?? []).flatMap((declaration: StyleDeclaration) => { + if (Array.isArray(declaration)) { + const propertyPath = declaration[1]; + return typeof propertyPath === "string" + ? [propertyPath] + : [propertyPath.join(".")]; + } + + return Object.keys(declaration); + }); +}; + +/** The value a rule sets for a property, in whichever declaration form it took. */ +const declaredValue = ( + rule: StyleRule, + property: string, +): StyleDescriptor | undefined => { + for (const declaration of rule.d ?? []) { + if (Array.isArray(declaration)) { + const propertyPath = declaration[1]; + const name = + typeof propertyPath === "string" + ? propertyPath + : propertyPath.join("."); + + if (name === property) { + return declaration[0]; + } + } else if (property in declaration) { + return declaration[property]; + } + } + + return undefined; +}; + +const variable = ( + rule: StyleRule, + name: string, +): StyleDescriptor | undefined => { + return rule.v?.find(([variableName]) => variableName === name)?.[1]; +}; + +describe("an extra rule carries only its own declaration", () => { + /** + * A `var()` anywhere inside `light-dark()` keeps the colour unresolved, which + * is the path that opens the extra rule from the current rule rather than + * from an empty one. Two such declarations on one rule is the trigger: a + * colour and a background colour together is ordinary in a themed stylesheet. + */ + const twoUnresolvedDeclarations = ` +.p1 { + color: light-dark(hsl(0 100% 50% / var(--a)), hsl(240 100% 50% / var(--a))); + background-color: light-dark(hsl(120 100% 25% / var(--a)), hsl(60 100% 50% / var(--a))); +}`; + + test("each declaration opens one dark rule", () => { + expect(darkRules(twoUnresolvedDeclarations, "p1")).toHaveLength(2); + }); + + test("a dark rule sets the properties of its own declaration, and no others", () => { + expect( + darkRules(twoUnresolvedDeclarations, "p1").map(declaredProperties), + ).toStrictEqual([["color"], ["backgroundColor"]]); + }); + + test("the second declaration's dark rule does not re-assert the first's light value", () => { + const [, backgroundDarkRule] = darkRules(twoUnresolvedDeclarations, "p1"); + + expect(backgroundDarkRule?.d).toStrictEqual([ + [[{}, "hsl", [60, 100, 50, [{}, "var", "a", 1]]], "backgroundColor", 1], + ]); + }); +}); + +describe("an extra rule carries its own flags", () => { + /** + * Only the dark branch reads a variable, so `dv` is the extra rule's own — the + * rule it copies has no delayed declaration to inherit the flag from. + */ + const darkBranchVariable = `.p2 { background-color: light-dark(red, var(--d)); }`; + + test("the light rule needs no delayed resolution", () => { + expect(lightRule(darkBranchVariable, "p2").dv).toBeUndefined(); + }); + + test("the dark rule declares delayed resolution for its own variable", () => { + expect(darkRules(darkBranchVariable, "p2")).toHaveLength(1); + expect(darkRules(darkBranchVariable, "p2")[0]?.dv).toBe(1); + }); +}); + +describe("an extra rule publishes its own inherited colour", () => { + const lightDarkColor = `.p3 { color: light-dark(red, blue); }`; + + test("the light rule publishes the light colour", () => { + expect(variable(lightRule(lightDarkColor, "p3"), "__rn-css-color")).toBe( + "#f00", + ); + }); + + /** + * Stated over every dark rule rather than over a count of them, because how + * many parses a colour declaration takes is not this rule's subject — that a + * dark rule never hands descendants the light colour is. + */ + test("no dark rule publishes the light colour", () => { + const published = darkRules(lightDarkColor, "p3").map((rule) => + variable(rule, "__rn-css-color"), + ); + + expect(published.length).toBeGreaterThan(0); + expect(published).not.toContain("#f00"); + }); + + test("a dark rule publishes the dark colour", () => { + const published = darkRules(lightDarkColor, "p3").map((rule) => + variable(rule, "__rn-css-color"), + ); + + expect(published).toContain("#00f"); + }); + + /** + * A `var()` in either branch keeps the whole declaration unparsed, which + * publishes the variable from a different place than the parsed colour path. + */ + const unresolvedLightDarkColor = `.p4 { color: light-dark(red, var(--d)); }`; + + test("an unresolved dark branch publishes itself, not the light colour", () => { + const published = darkRules(unresolvedLightDarkColor, "p4").map((rule) => + variable(rule, "__rn-css-color"), + ); + + expect(published).toStrictEqual([[{}, "var", "d", 1]]); + }); +}); + +describe("an extra rule leaves the rest of the rule alone", () => { + const withOtherVariable = `.p5 { --other: 5px; color: light-dark(red, blue); }`; + // The variable has to survive compilation to be observable in the output. + const keepVariables: CompilerOptions = { inlineVariables: false }; + + test("the light rule publishes both variables", () => { + expect(lightRule(withOtherVariable, "p5", keepVariables).v).toStrictEqual([ + ["other", 5], + ["__rn-css-color", "#f00"], + ]); + }); + + /** + * The rule an extra rule copies matches under the extra condition too, so a + * variable the extra rule does not restate still reaches the element from + * there. Restating it would make every extra rule a second place the value is + * written. + */ + test("a dark rule restates only the variable it changes", () => { + const rules = darkRules(withOtherVariable, "p5", keepVariables); + + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) { + expect(rule.v).toStrictEqual([["__rn-css-color", "#00f"]]); + } + }); +}); + +describe("an extra rule publishes nothing it does not change", () => { + /** + * Two `light-dark()` declarations open two extra rules on one rule, and only + * one of them publishes a colour — `background-color` is not inherited. The + * other is applied last, so anything it restates from the rule it copies + * overwrites what the first one published. + */ + const resolved = ` +.p6 { + color: light-dark(red, blue); + background-color: light-dark(#0f0, #ff0); +}`; + + /** + * A `var()` in either branch keeps the colour unresolved, which publishes the + * variable from a different place than the parsed colour path — so the same + * invariant is stated over both. + */ + const unresolved = ` +.p7 { + color: light-dark(hsl(0 100% 50% / var(--a)), hsl(240 100% 50% / var(--a))); + background-color: light-dark(hsl(120 100% 25% / var(--a)), hsl(60 100% 50% / var(--a))); +}`; + + test.each([ + ["a parsed colour", resolved, "p6"], + ["an unresolved colour", unresolved, "p7"], + ])("%s: no dark rule republishes the light colour", (_, css, className) => { + const published = darkRules(css, className).map((rule) => + variable(rule, "__rn-css-color"), + ); + + expect(published.length).toBeGreaterThan(0); + // Structural, not identity: an unresolved colour publishes an object. + expect(published).not.toContainEqual( + variable(lightRule(css, className), "__rn-css-color"), + ); + }); + + test.each([ + ["a parsed colour", resolved, "p6"], + ["an unresolved colour", unresolved, "p7"], + ])( + "%s: the background's dark rule publishes nothing at all", + (_, css, className) => { + const backgroundDarkRule = darkRules(css, className).find((rule) => + declaredProperties(rule).includes("backgroundColor"), + ); + + expect(backgroundDarkRule).toBeDefined(); + expect(backgroundDarkRule?.v).toBeUndefined(); + }, + ); + + test("a parsed colour: a dark rule publishes the dark colour", () => { + const published = darkRules(resolved, "p6").map((rule) => + variable(rule, "__rn-css-color"), + ); + + expect(published).toContain("#00f"); + }); +}); + +describe("an extra rule is scoped to the pseudo-element its rule was", () => { + /** + * `::selection` maps `color` onto `selectionColor`, `::placeholder` onto + * `placeholderTextColor`. The mapping is applied to the rule on its way to + * the selector, so an extra rule composed after that step is a declaration + * the pseudo-element asked for landing on the element itself. + */ + const pseudoElements = [ + ["::selection", "selectionColor"], + ["::placeholder", "placeholderTextColor"], + ] as const; + + test.each(pseudoElements)( + "%s: every rule sets %s, and no rule sets color", + (pseudoElement, property) => { + const css = `.p8${pseudoElement} { color: light-dark(red, blue); }`; + const rules = rulesFor(css, "p8"); + + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) { + expect(declaredProperties(rule)).toStrictEqual([property]); + } + }, + ); + + test.each(pseudoElements)( + "%s: the dark rule sets %s to the dark colour", + (pseudoElement, property) => { + const css = `.p8${pseudoElement} { color: light-dark(red, blue); }`; + + expect(declaredValue(lightRule(css, "p8"), property)).toBe("#f00"); + + const dark = darkRules(css, "p8"); + expect(dark.length).toBeGreaterThan(0); + for (const rule of dark) { + expect(declaredValue(rule, property)).toBe("#00f"); + } + }, + ); +}); + +describe("an extra rule matches where the rule it was opened on matched", () => { + /** + * The rule an extra rule is opened on supplies the conditions it already + * matched under, and the extra rule adds its own to them. A dark rule that + * dropped one of them applies where the declaration it carries never + * appeared — outside the container, or at any width. + */ + const inContainer = `@container box (min-width: 100px) { .p10 { color: light-dark(red, blue); } }`; + + test("the dark rule is scoped to the container its rule was", () => { + const containerQuery = lightRule(inContainer, "p10").cq; + const dark = darkRules(inContainer, "p10"); + + expect(containerQuery?.length).toBeGreaterThan(0); + expect(dark.length).toBeGreaterThan(0); + for (const rule of dark) { + expect(rule.cq).toStrictEqual(containerQuery); + } + }); + + const inMediaQuery = `@media (min-width: 100px) { .p11 { color: light-dark(red, blue); } }`; + + test("the dark rule adds its condition to the ones its rule already carried", () => { + const mediaConditions = lightRule(inMediaQuery, "p11").m; + const dark = darkRules(inMediaQuery, "p11"); + + expect(mediaConditions?.length).toBeGreaterThan(0); + expect(dark.length).toBeGreaterThan(0); + for (const rule of dark) { + expect(rule.m).toStrictEqual([ + ...(mediaConditions ?? []), + ["=", "prefers-color-scheme", "dark"], + ]); + } + }); +}); + +describe("an extra rule matches under the conditions of its selector", () => { + /** + * A pseudo class and an attribute query describe the SELECTOR rather than the + * rule, so they reach every rule the selector applies to — an extra rule has + * no copy of its own to carry, and needs none. + */ + const withPseudoClass = `.p12:hover { color: light-dark(red, blue); }`; + + test("the dark rule carries the pseudo class its selector named", () => { + const pseudoClasses = lightRule(withPseudoClass, "p12").p; + const dark = darkRules(withPseudoClass, "p12"); + + expect(Object.keys(pseudoClasses ?? {}).length).toBeGreaterThan(0); + expect(dark.length).toBeGreaterThan(0); + for (const rule of dark) { + expect(rule.p).toStrictEqual(pseudoClasses); + } + }); + + const withAttributeQuery = `.p13[data-x="1"] { color: light-dark(red, blue); }`; + + test("the dark rule carries the attribute query its selector named", () => { + const attributeQuery = lightRule(withAttributeQuery, "p13").aq; + const dark = darkRules(withAttributeQuery, "p13"); + + expect(attributeQuery?.length).toBeGreaterThan(0); + expect(dark.length).toBeGreaterThan(0); + for (const rule of dark) { + expect(rule.aq).toStrictEqual(attributeQuery); + } + }); +}); + +describe("a container query names its parent classes once per selector", () => { + /** + * The parent classes a container query names describe the SELECTOR, not the + * rule, so how many rules the selector receives cannot change how many times + * they are registered. Every `light-dark()` declaration adds one more rule, + * and the registration is the same either way. + */ + const oneRule = `.p14-container .p14 { color: red; }`; + const manyRules = ` +.p14-container .p14 { + color: light-dark(red, blue); + background-color: light-dark(green, yellow); + border-top-color: light-dark(cyan, magenta); +}`; + + test("every light-dark() declaration adds a rule to the selector", () => { + expect(rulesFor(oneRule, "p14")).toHaveLength(1); + expect(rulesFor(manyRules, "p14").length).toBeGreaterThan( + rulesFor(oneRule, "p14").length, + ); + }); + + test("the container class is registered once, whatever the selector receives", () => { + expect(rulesFor(manyRules, "p14-container")).toHaveLength(1); + expect(rulesFor(manyRules, "p14-container")).toStrictEqual( + rulesFor(oneRule, "p14-container"), + ); + }); +}); + +describe("a light-dark() declaration opens one extra rule", () => { + /** + * `color` writes twice — the style property, and the variable it publishes to + * its subtree — and `parseColor` is not pure: a `light-dark()` value opens an + * extra rule. Parsing the value once for both writes is what keeps a colour + * declaration to one dark rule. + * + * A shorthand parses its value once per longhand it expands to, so it opens + * one extra rule per parse: `border-color` emits four identical dark rules, + * `border-inline-color` and `border-block-color` two each. Same impurity, a + * different caller, and not what this describe measures. + */ + test.each([ + ["color", `.p9 { color: light-dark(red, blue); }`], + ["background-color", `.p9 { background-color: light-dark(red, blue); }`], + ])("%s", (_, css) => { + expect(darkRules(css, "p9")).toHaveLength(1); + }); +}); diff --git a/src/__tests__/native/light-dark.test.tsx b/src/__tests__/native/light-dark.test.tsx new file mode 100644 index 00000000..6b424623 --- /dev/null +++ b/src/__tests__/native/light-dark.test.tsx @@ -0,0 +1,273 @@ +import { act, 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"; +import { colorScheme } from "react-native-css/runtime"; + +afterEach(() => { + act(() => { + colorScheme.set("light"); + }); +}); + +describe("two light-dark() declarations on one rule", () => { + /** + * A `var()` anywhere inside `light-dark()` keeps the colour unresolved, which + * is the path that opens the dark rule from the current rule rather than from + * an empty one — so the second declaration's dark rule re-asserts the first + * declaration's light value over the dark one already set. + */ + const css = ` +:root { --a: 1; } +.my-class { + color: light-dark(hsl(0 100% 50% / var(--a)), hsl(240 100% 50% / var(--a))); + background-color: light-dark(hsl(120 100% 25% / var(--a)), hsl(60 100% 50% / var(--a))); +}`; + + test("light mode takes both light branches", () => { + registerCSS(css); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "hsl(0, 100, 50)", + backgroundColor: "hsl(120, 100, 25)", + }); + }); + + test("dark mode takes both dark branches", () => { + registerCSS(css); + render(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "hsl(240, 100, 50)", + backgroundColor: "hsl(60, 100, 50)", + }); + }); +}); + +describe("a light-dark() branch that reads a variable", () => { + /** + * Only the dark branch reads a variable, so resolving it is the extra rule's + * own requirement — the rule it copies has no delayed declaration to inherit + * the flag from. + */ + const css = ` +.parent { --d: blue; } +.my-class { background-color: light-dark(red, var(--d)); } +.plain { background-color: var(--d); }`; + + const renderTree = () => { + // The variable has to survive compilation for the runtime to resolve it. + registerCSS(css, { inlineVariables: false }); + render( + + + + , + ); + }; + + test("light mode takes the static branch", () => { + renderTree(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "red", + }); + }); + + test("dark mode resolves the variable branch", () => { + renderTree(); + + act(() => { + colorScheme.set("dark"); + }); + + // Stated against the same variable read outside light-dark(), so the test + // pins that the branch resolves rather than how the value stringifies. + expect(screen.getByTestId(testID).props.style).toStrictEqual( + screen.getByTestId("plain").props.style, + ); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "blue", + }); + }); +}); + +describe("a light-dark() colour is inherited per colour scheme", () => { + const css = ` +.parent { color: light-dark(red, blue); } +.child { background-color: currentcolor; }`; + + const renderTree = () => { + registerCSS(css); + render( + + + , + ); + }; + + test("light mode hands descendants the light colour", () => { + renderTree(); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#f00", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + backgroundColor: "#f00", + }); + }); + + test("dark mode hands descendants the dark colour", () => { + renderTree(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#00f", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + backgroundColor: "#00f", + }); + }); +}); + +describe("a light-dark() colour leaves the rule's other variables alone", () => { + /** + * The dark rule restates only the variable its own branch changes. The rule + * it copies matches under `prefers-color-scheme: dark` too, so every other + * variable still reaches descendants from there. + */ + const css = ` +.parent { --other: 5px; color: light-dark(red, blue); } +.child { width: var(--other); background-color: currentcolor; }`; + + const renderTree = () => { + registerCSS(css, { inlineVariables: false }); + render( + + + , + ); + }; + + test("light mode", () => { + renderTree(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + backgroundColor: "#f00", + }); + }); + + test("dark mode keeps the untouched variable and swaps the colour", () => { + renderTree(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + backgroundColor: "#00f", + }); + }); +}); + +describe("two light-dark() colours are inherited per colour scheme", () => { + /** + * Only `color` publishes to the subtree, and its extra rule is not the last + * one opened on this rule — `background-color` opens one after it. Anything + * that last rule restates from the rule it copies lands on top of what the + * colour published. + */ + const css = ` +.parent { color: light-dark(#f00, #00f); background-color: light-dark(#0f0, #ff0); } +.child { background-color: currentcolor; }`; + + const renderTree = () => { + registerCSS(css); + render( + + + , + ); + }; + + test("light mode hands descendants the light colour", () => { + renderTree(); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#f00", + backgroundColor: "#0f0", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + backgroundColor: "#f00", + }); + }); + + test("dark mode hands descendants the dark colour", () => { + renderTree(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#00f", + backgroundColor: "#ff0", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + backgroundColor: "#00f", + }); + }); +}); + +describe("a light-dark() inside a pseudo-element", () => { + /** + * `::selection` and `::placeholder` map their `color` onto a prop of the host + * component. The dark branch arrives as its own rule, and it has to be mapped + * the same way — an unmapped one is a `color` on the element, which is the + * text the pseudo-element was never asking about. + */ + const cases = [ + ["::selection", "selectionColor"], + ["::placeholder", "placeholderTextColor"], + ] as const; + + test.each(cases)( + "%s: light mode tints with the light colour", + (pseudoElement, prop) => { + registerCSS( + `.my-class${pseudoElement} { color: light-dark(#f00, #00f); }`, + ); + render(); + + expect(screen.getByTestId(testID).props[prop]).toBe("#f00"); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }, + ); + + test.each(cases)( + "%s: dark mode tints with the dark colour", + (pseudoElement, prop) => { + registerCSS( + `.my-class${pseudoElement} { color: light-dark(#f00, #00f); }`, + ); + render(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + expect(screen.getByTestId(testID).props[prop]).toBe("#00f"); + }, + ); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..37279f4a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -38,9 +38,9 @@ import type { import { isStyleFunction } from "../utilities"; import type { + MediaCondition, StyleDescriptor, StyleFunction, - StyleRule, } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; import { toRNProperty } from "./selectors"; @@ -48,6 +48,16 @@ import type { StylesheetBuilder } from "./stylesheet"; const CommaSeparator = Symbol("CommaSeparator"); +/** The condition an extra rule for a `light-dark()` dark branch is gated on. */ +const DARK_COLOR_SCHEME: MediaCondition = ["=", "prefers-color-scheme", "dark"]; + +/** + * The variable a `color` declaration publishes to its subtree, and that + * `currentcolor` reads back. Named as the custom property it is declared as — + * `addDescriptor` strips the `--` prefix. + */ +const INHERITED_COLOR_PROPERTY = "--__rn-css-color"; + type DeclarationType

= Extract< Declaration, { property: P } @@ -290,7 +300,7 @@ function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; - builder.descriptorProperty = declaration.property; + builder.descriptorProperties = [declaration.property]; builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); @@ -930,7 +940,8 @@ export function parseUnparsedDeclaration( /** * Unparsed shorthand properties need to be parsed at runtime */ - builder.descriptorProperty = property; + builder.descriptorProperties = + property === "color" ? [property, INHERITED_COLOR_PROPERTY] : [property]; if (unparsedRuntimeParsing.has(property)) { const args = parseUnparsed(declaration.value.value, builder, property); @@ -955,7 +966,7 @@ export function parseUnparsedDeclaration( value[1] !== "var" || value[2] !== "-css-color" ) { - builder.addDescriptor("--__rn-css-color", value); + builder.addDescriptor(INHERITED_COLOR_PROPERTY, value); } } } @@ -1589,16 +1600,31 @@ export function parseFontColorDeclaration( declaration: Extract, builder: StylesheetBuilder, ) { - parseColorDeclaration(declaration, builder); + /** + * `color` writes twice: the style property, and the variable it publishes to + * its subtree. A `light-dark()` dark branch is written through + * `addUnnamedDescriptor`, which reaches every property named here — so both + * have to be named, or the extra rule publishes the light colour in dark mode. + */ + builder.descriptorProperties = [ + declaration.property, + INHERITED_COLOR_PROPERTY, + ]; + + /** + * Parsed once for both writes. `parseColor` is not pure — a `light-dark()` + * value opens an extra rule — so parsing the same value a second time opens a + * second, identical dark rule. + */ + const value = parseColor(declaration.value, builder); + + builder.addDescriptor(declaration.property, value); if ( typeof declaration.value !== "object" || declaration.value.type !== "currentcolor" ) { - builder.addDescriptor( - "--__rn-css-color", - parseColor(declaration.value, builder), - ); + builder.addDescriptor(INHERITED_COLOR_PROPERTY, value); } } @@ -1640,17 +1666,13 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { case "currentcolor": return [{}, "var", "__rn-css-color"] as const; case "light-dark": { - const extraRule: StyleRule = { - s: [], - m: [["=", "prefers-color-scheme", "dark"]], - }; + const extraRule = builder.openExtraRule(DARK_COLOR_SCHEME); builder.addUnnamedDescriptor( parseColor(cssColor.dark, builder), false, extraRule, ); - builder.addExtraRule(extraRule); return parseColor(cssColor.light, builder); } case "rgb": { @@ -2910,15 +2932,13 @@ export function parseUnresolvedColor( ], ]; case "light-dark": { - const extraRule = builder.extendRule({ - m: [["=", "prefers-color-scheme", "dark"]], - }); + const extraRule = builder.openExtraRule(DARK_COLOR_SCHEME); + builder.addUnnamedDescriptor( reduceParseUnparsed(color.dark, builder, property, allowAuto), false, extraRule, ); - builder.addExtraRule(extraRule); return reduceParseUnparsed(color.light, builder, property, allowAuto); } default: diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..3884791d 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -34,7 +34,7 @@ const staticDeclarations = new WeakMap< Record >(); -const extraRules = new WeakMap[]>(); +const extraRules = new WeakMap(); const keywords = new Set(["unset"]); @@ -58,7 +58,12 @@ export class StylesheetBuilder { }, // Any default mapping should be included in the @nativeMapping parsing private mapping: StyleRuleMapping = {}, - public descriptorProperty?: string, + /** + * The properties the declaration being parsed writes to. Usually one, but a + * declaration that also publishes a variable writes to every one of them, + * and an unnamed descriptor has to reach all of them. + */ + public descriptorProperties?: readonly string[], private shared: { ruleSets: Record; rootVariables?: VariableRecord; @@ -102,7 +107,7 @@ export class StylesheetBuilder { mode, this.cloneRule(), { ...this.mapping }, - this.descriptorProperty, + this.descriptorProperties, this.shared, selectors, ); @@ -121,23 +126,40 @@ export class StylesheetBuilder { return rule; } - private createRuleFromPartial(rule: StyleRule, partial: Partial) { - rule = this.cloneRule(rule); - - if (partial.m) { - rule.m ??= []; - rule.m.push(...partial.m); + /** + * The form an extra rule takes on its way to a selector. + * + * The rule it was opened on supplies the SELECTOR — its specificity, its + * container query, and the media conditions the extra one is added to. The + * extra rule supplies the CONTENT, in full: its declarations, the variables + * they publish, and the flags they set. Neither half crosses over. + * + * Pseudo classes and attribute queries are absent from that list because + * they are never on the rule this copies from. They are read off the + * selector by `applyRuleToSelectors` and written onto every rule it applies, + * this one included, so copying them here would be a second source for a + * value that already reaches the merged rule one step later. + * + * Content is never inherited, not even for a channel the extra rule leaves + * empty. The rule it was opened on matches under the extra condition too, so + * anything left out still arrives from there — while restating it makes the + * extra rule a second place that value is written, and being applied last it + * overwrites whatever an earlier extra rule on the same rule published. + */ + private mergeExtraRule(rule: StyleRule, extraRule: StyleRule): StyleRule { + const merged = this.cloneRule(extraRule); + + merged.s = [...rule.s]; + + if (rule.m) { + merged.m = [...rule.m, ...(merged.m ?? [])]; } - if (partial.d) { - rule.d = partial.d; + if (rule.cq) { + merged.cq = [...rule.cq]; } - return rule; - } - - extendRule(rule: Partial) { - return this.cloneRule({ ...this.rule, ...rule }); + return merged; } getOptions(): CompilerOptions { @@ -267,14 +289,30 @@ export class StylesheetBuilder { this.newRule(mapping, { important }); } - /** Hack for light-dark, which requires adding a new rule without changing the current rule */ - addExtraRule(rule: Partial) { + /** + * Open an extra rule: the current rule again under one more media condition, + * for a declaration whose value differs under that condition. `light-dark()` + * is the caller — it resolves to two values where a declaration parser + * returns one, so the dark branch is delivered by an extra rule under + * `prefers-color-scheme: dark`. + * + * The rule is created EMPTY and returned for the caller to write descriptors + * into through the same `addDescriptor` seams as the current rule. It is + * never seeded from the current rule: a copy taken mid-parse carries every + * value written before it, so a second `light-dark()` on the same rule would + * hand its dark rule the first declaration's light value. + */ + openExtraRule(condition: MediaCondition): StyleRule { + const extraRule: StyleRule = { s: [], m: [condition] }; + let extraRuleArray = extraRules.get(this.rule); if (!extraRuleArray) { extraRuleArray = []; extraRules.set(this.rule, extraRuleArray); } - extraRuleArray.push(rule); + extraRuleArray.push(extraRule); + + return extraRule; } private addRuleToRuleSet(name: string, rule = this.rule) { @@ -305,11 +343,13 @@ export class StylesheetBuilder { forceTuple?: boolean, rule = this.rule, ) { - if (this.descriptorProperty === undefined) { + if (this.descriptorProperties === undefined) { return; } - this.addDescriptor(this.descriptorProperty, value, forceTuple, rule); + for (const property of this.descriptorProperties) { + this.addDescriptor(property, value, forceTuple, rule); + } } addDescriptor( @@ -447,34 +487,78 @@ export class StylesheetBuilder { this.options, ); + /** + * The rules a selector receives: the current rule, and every extra rule + * opened on it already carrying the current rule's selector context. They + * are applied identically from here — a step the current rule takes and an + * extra rule skips is a step the selector never applied to it. + */ + const extraRulesArray = extraRules.get(this.rule) ?? []; + const sourceRules = [ + this.rule, + ...extraRulesArray.map((extraRule) => + this.mergeExtraRule(this.rule, extraRule), + ), + ]; + 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") { + // These can only have variable declarations + if (!this.rule.v) { + continue; + } + const { type } = selector; + for (const [name, value] of this.rule.v) { + this.shared[type] ??= {}; + this.shared[type][name] ??= []; + const mediaQueries = this.rule.m; + const variableValue: VariableValue = mediaQueries + ? [value, [...mediaQueries]] + : [value]; + // Append extra media queries if they exist + this.shared[type][name].push(variableValue); + + if (type === "rootVariables" && name === "__rn-css-em") { + const remName = "__rn-css-rem"; + this.shared[type][remName] ??= []; + this.shared[type][remName].push(variableValue); + } } - } - if (!rule) { continue; } - if (selector.type === "className") { - const { - specificity, - className, - mediaQuery, - containerQuery, - pseudoClassesQuery, - attributeQuery, - } = selector; - - if (!className) { - continue; // No className, nothing to do + const { + specificity, + className, + mediaQuery, + containerQuery, + pseudoClassesQuery, + attributeQuery, + } = selector; + + if (!className) { + continue; // No className, nothing to do + } + + // The parent classes a container query names describe the selector, not + // the rule, so they are registered once however many rules it receives. + let parentContainersRegistered = false; + + for (const sourceRule of sourceRules) { + // We are going to be apply the rule to n selectors, so we clone the rule + let rule: StyleRule | undefined = this.cloneRule(sourceRule); + + if (selector.pseudoElementQuery) { + if (selector.pseudoElementQuery.includes("selection")) { + rule = modifyRuleForSelection(rule); + } else if (selector.pseudoElementQuery.includes("placeholder")) { + rule = modifyRuleForPlaceholder(rule); + } + } + + if (!rule) { + continue; } // Combine the specificity of the selector with the rule's specificity @@ -493,36 +577,40 @@ export class StylesheetBuilder { rule.cq ??= []; rule.cq.push(...containerQuery); - for (const query of containerQuery) { - const name = query.n; + if (!parentContainersRegistered) { + parentContainersRegistered = true; - if (typeof name !== "string") { - continue; - } + for (const query of containerQuery) { + const name = query.n; - const [first, ...rest] = name.slice(2).split("."); + if (typeof name !== "string") { + continue; + } - if (typeof first !== "string") { - continue; - } + const [first, ...rest] = name.slice(2).split("."); - const containerRule: StyleRule = { - // These are not "real" rules, so they use the lowest specificity - s: [0], - c: [name], - }; - - if (rest.length) { - containerRule.aq = rest.map((attr) => [ - "a", - "className", - "*=", - attr, - ]); - } + if (typeof first !== "string") { + continue; + } + + const containerRule: StyleRule = { + // These are not "real" rules, so they use the lowest specificity + s: [0], + c: [name], + }; - // Create rules for the parent classes - this.addRuleToRuleSet(first, containerRule); + if (rest.length) { + containerRule.aq = rest.map((attr) => [ + "a", + "className", + "*=", + attr, + ]); + } + + // Create rules for the parent classes + this.addRuleToRuleSet(first, containerRule); + } } } @@ -536,38 +624,6 @@ export class StylesheetBuilder { } this.addRuleToRuleSet(className, rule); - - const extraRulesArray = extraRules.get(this.rule); - if (extraRulesArray) { - for (const extraRule of extraRulesArray) { - this.addRuleToRuleSet( - className, - this.createRuleFromPartial(rule, extraRule), - ); - } - } - } else { - // These can only have variable declarations - if (!this.rule.v) { - continue; - } - const { type } = selector; - for (const [name, value] of this.rule.v) { - this.shared[type] ??= {}; - this.shared[type][name] ??= []; - const mediaQueries = this.rule.m; - const variableValue: VariableValue = mediaQueries - ? [value, [...mediaQueries]] - : [value]; - // Append extra media queries if they exist - this.shared[type][name].push(variableValue); - - if (type === "rootVariables" && name === "__rn-css-em") { - const remName = "__rn-css-rem"; - this.shared[type][remName] ??= []; - this.shared[type][remName].push(variableValue); - } - } } } }