diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 119c85d7..69678056 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -10,9 +10,9 @@ test("@property with length initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.has("tw-translate-x")).toBe(true); expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); }); @@ -26,6 +26,7 @@ test("@property without initial value is skipped", () => { `); const result = compiled.stylesheet(); + expect(result.vi).toBeUndefined(); expect(result.vr).toBeUndefined(); }); @@ -39,9 +40,9 @@ test("@property with number initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-backdrop-opacity")).toStrictEqual([[1]]); }); @@ -55,9 +56,9 @@ test("@property with color initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-ring-offset-color")).toStrictEqual([["#fff"]]); }); @@ -71,13 +72,13 @@ test("@property with token-list initial value (shadow)", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-shadow")).toStrictEqual([[[0, 0, "#0000"]]]); }); -test("@property defaults are root variables, not universal", () => { +test("@property defaults are neither root nor universal variables", () => { const compiled = compile(` @property --tw-shadow { syntax: "*"; @@ -87,10 +88,27 @@ test("@property defaults are root variables, not universal", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); + expect(result.vr).toBeUndefined(); expect(result.vu).toBeUndefined(); }); +test("a :root declaration and a registered default land in different slots", () => { + const compiled = compile(` +@property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; +} +:root { --my-var: 50px; } +:root { --my-var: 50px; } +`); + + const result = compiled.stylesheet(); + expect(new Map(result.vr).get("my-var")).toStrictEqual([[50]]); + expect(new Map(result.vi).get("my-var")).toStrictEqual([[0]]); +}); + test("@supports -moz-orient fallback no longer fires", () => { const compiled = compile(` @supports (-moz-orient: inline) { @@ -139,7 +157,7 @@ test("@property + class override produces valid stylesheet", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); expect(result.s).toBeDefined(); const shadowRule = result.s?.find(([name]) => name === "shadow-md"); @@ -156,9 +174,9 @@ test("@property with percentage initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-shadow-alpha")).toStrictEqual([["100%"]]); }); @@ -182,9 +200,9 @@ test("multiple @property declarations with verified values", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); expect(vrMap.get("tw-translate-y")).toStrictEqual([[0]]); expect(vrMap.get("tw-rotate")).toStrictEqual([["0deg"]]); @@ -200,14 +218,151 @@ test("@property with repeated single-child unwraps to scalar", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); // Single-child repeated (+ with one value) should unwrap // to the same shape as a direct type expect(vrMap.get("my-offset")).toStrictEqual([[10]]); }); +test("@property inherits: false is recorded, initial value or not", () => { + const compiled = compile(` +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +`); + + const result = compiled.stylesheet(); + expect(result.vn).toStrictEqual(["tw-ring-shadow", "tw-ring-color"]); + expect(new Map(result.vi).has("tw-ring-color")).toBe(false); +}); + +test("vn carries exactly the properties declared inherits: false", () => { + const declarations: [name: string, inherits: boolean][] = [ + ["--a-off", false], + ["--b-on", true], + ["--c-off", false], + ["--d-on", true], + ]; + + const compiled = compile( + declarations + .map( + ([name, inherits]) => + `@property ${name} { syntax: ""; inherits: ${inherits}; initial-value: 0px; }`, + ) + .join("\n"), + ); + + expect(declarations.length).toBeGreaterThan(0); + expect([...(compiled.stylesheet().vn ?? [])].sort()).toStrictEqual( + declarations + .filter(([, inherits]) => !inherits) + .map(([name]) => name.slice(2)) + .sort(), + ); +}); + +test("a non-inheriting property is left to the runtime, however few rules declare it", () => { + const compiled = compile(` +@property --pinned { + syntax: ""; + inherits: false; + initial-value: 0px; +} +.parent { --pinned: 10px; } +.child { width: var(--pinned); } +`); + + const result = compiled.stylesheet(); + const child = result.s?.find(([name]) => name === "child"); + + expect(result.s?.find(([name]) => name === "parent")).toBeDefined(); + expect(JSON.stringify(child)).toContain('"var"'); +}); + +test("an inheriting property with one declaration is still inlined", () => { + const compiled = compile(` +@property --folded { + syntax: ""; + inherits: true; + initial-value: 0px; +} +.parent { --folded: 10px; } +.child { width: var(--folded); } +`); + + const result = compiled.stylesheet(); + const child = result.s?.find(([name]) => name === "child"); + + expect(JSON.stringify(child)).not.toContain('"var"'); + expect(JSON.stringify(child)).toContain("10"); +}); + +test("@property inherits: true is not recorded", () => { + const compiled = compile(` +@property --my-brand { + syntax: ""; + inherits: true; + initial-value: red; +} +`); + + const result = compiled.stylesheet(); + expect(result.vn).toBeUndefined(); +}); + +test("an unregistered custom property is not recorded", () => { + // Declared twice, since a single declaration is inlined away and the sheet would be empty + const compiled = compile(` +.my-class { --my-var: 10px; } +.other { --my-var: 20px; } +`); + + const result = compiled.stylesheet(); + expect(result.s).toBeDefined(); + expect(result.vn).toBeUndefined(); + expect(result.vi).toBeUndefined(); +}); + +test("@property records a name once, however many rules declare it", () => { + // Different syntaxes, since lightningcss collapses identical @property rules + const compiled = compile(` +@property --dup { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --dup { + syntax: "*"; + inherits: false; +} +`); + + expect(compiled.stylesheet().vn).toStrictEqual(["dup"]); +}); + +test("the last @property declaration of a name decides inherits", () => { + const inheritsLast = compile(` +@property --flip { syntax: "*"; inherits: false; } +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +`); + expect(inheritsLast.stylesheet().vn).toBeUndefined(); + + const nonInheritingLast = compile(` +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +@property --flip { syntax: "*"; inherits: false; } +`); + expect(nonInheritingLast.stylesheet().vn).toStrictEqual(["flip"]); +}); + test("@property with repeated multi-child preserves array", () => { const compiled = compile(` @property --my-offsets { @@ -218,9 +373,99 @@ test("@property with repeated multi-child preserves array", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); // Multi-child repeated (+ with two values) keeps the array form expect(vrMap.get("my-offsets")).toStrictEqual([[[10, 20]]]); }); + +test.each([ + "2em", + "1rem", + "1ch", + "2lh", + "1cqw", + "calc(1px + 1em)", + "min(1px, 1em)", +])( + "an initial value of %s depends on the element, so the registration is ignored", + (initialValue) => { + const result = compile( + `@property --x { syntax: ""; inherits: false; initial-value: ${initialValue}; }`, + ).stylesheet(); + + expect(result.vn).toBeUndefined(); + expect(result.vi).toBeUndefined(); + }, +); + +test.each([ + ["", "3px", 3], + ["", "calc(1px + 2px)", 3], + ["", "30%", "30%"], +])("a %s initial value of %s registers", (syntax, initialValue, expected) => { + const result = compile( + `@property --x { syntax: "${syntax}"; inherits: false; initial-value: ${initialValue}; }`, + ).stylesheet(); + + expect(result.vn).toStrictEqual(["x"]); + expect(new Map(result.vi).get("x")).toStrictEqual([[expected]]); +}); + +test.each(["1vw", "2svh", "max(1px, 2vw)"])( + "a viewport-relative initial value of %s registers", + (initialValue) => { + const result = compile( + `@property --x { syntax: ""; inherits: false; initial-value: ${initialValue}; }`, + ).stylesheet(); + + expect(result.vn).toStrictEqual(["x"]); + }, +); + +test.each([ + ["translateX(1em)", undefined], + ["translateX(30%)", ["x"]], +])( + "a transform initial value of %s registers only when it is element-independent", + (initialValue, expected) => { + const result = compile( + `@property --x { syntax: ""; inherits: false; initial-value: ${initialValue}; }`, + ).stylesheet(); + + expect(result.vn).toStrictEqual(expected); + }, +); + +test("an ignored registration leaves a single declaration to the inliner", () => { + const compiled = compile(` +@property --ignored { + syntax: ""; + inherits: false; + initial-value: 2em; +} +.parent { --ignored: 10px; } +.child { width: var(--ignored); } +`); + + const child = compiled.stylesheet().s?.find(([name]) => name === "child"); + + expect(JSON.stringify(child)).not.toContain('"var"'); +}); + +test("a non-inheriting property declared once on * is still inlined", () => { + const compiled = compile(` +@property --everywhere { + syntax: "*"; + inherits: false; + initial-value: solid; +} +* { --everywhere: dashed; } +.child { border-style: var(--everywhere); } +`); + + const child = compiled.stylesheet().s?.find(([name]) => name === "child"); + + expect(JSON.stringify(child)).not.toContain('"var"'); +}); diff --git a/src/__tests__/native/non-inheriting-variables.test.tsx b/src/__tests__/native/non-inheriting-variables.test.tsx new file mode 100644 index 00000000..e5ca430b --- /dev/null +++ b/src/__tests__/native/non-inheriting-variables.test.tsx @@ -0,0 +1,678 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { VariableContextProvider } from "react-native-css"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { + nonInheritedVariables, + registeredInitialValues, + resetVariableRegistries, +} from "react-native-css/native-internal"; + +const parentTestID = "parent"; + +const registration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } +`; + +const inheritingRegistration = ` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } +`; + +const loadRootCopy = () => { + jest.resetModules(); + return jest.requireActual( + "../../native-internal/root", + ); +}; + +// A custom property declared once is folded into its consumers at compile time, so these declare theirs twice + +test("a non-inheriting custom property does not reach a descendant", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("a non-inheriting custom property does not reach a grandchild", () => { + registerCSS(` + ${registration} + .parent { --my-var: 11px; } + .other { --my-var: 20px; } + .mid { opacity: 1; } + .child { height: var(--my-var); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ height: 0 }); +}); + +test("an inheriting custom property still reaches a descendant", () => { + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an unregistered custom property still inherits", () => { + registerCSS(` + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a registration whose initial value depends on the element is ignored, so the property inherits", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 2em; + } + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a non-inheriting custom property still applies to the element declaring it", () => { + registerCSS(` + ${registration} + .self { --my-var: 10px; width: var(--my-var); } + .other { --my-var: 20px; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a descendant declaring the property itself wins over the ancestor", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { --my-var: 30px; width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 30 }); +}); + +test("a descendant falls through to the var() fallback when the registration has no initial value", () => { + registerCSS(` + @property --no-init { + syntax: "*"; + inherits: false; + } + .parent { --no-init: 10px; } + .other { --no-init: 20px; } + .child { width: var(--no-init, 99px); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 99 }); +}); + +test("VariableContextProvider does not publish a non-inheriting property", () => { + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("VariableContextProvider still publishes an inheriting property", () => { + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("VariableContextProvider withholds one property without withholding its siblings", () => { + registerCSS(` + ${registration} + @property --kept { + syntax: ""; + inherits: true; + initial-value: 0px; + } + .parent { --my-var: 1px; --kept: 1px; } + .other { --my-var: 2px; --kept: 2px; } + .child { width: var(--my-var); height: var(--kept); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + width: 0, + height: 20, + }); +}); + +test.each([ + [ + "after", + `${registration} :root { --my-var: 50px; } :root { --my-var: 50px; }`, + ], + [ + "before", + `:root { --my-var: 50px; } :root { --my-var: 50px; } ${registration}`, + ], + ["once, after", `${registration} :root { --my-var: 50px; }`], +])( + ":root does not supply a non-inheriting property to an element, declared %s the registration", + (_, css) => { + registerCSS(` + ${css} + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); + }, +); + +test("an ancestor class with a single declaration does not supply a non-inheriting property", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("a single-declaration non-inheriting property still applies to the element declaring it", () => { + registerCSS(` + ${registration} + .self { --my-var: 10px; width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test(":root still supplies an inheriting property to an element", () => { + registerCSS(` + ${inheritingRegistration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + +test("a :root declaration beats a registered initial value declared after it", () => { + registerCSS(` + :root { --my-var: 50px; } + :root { --my-var: 50px; } + ${inheritingRegistration} + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + +test("an element declaring a non-inheriting property beats :root for itself", () => { + registerCSS(` + ${registration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .self { --my-var: 30px; width: var(--my-var); } + .other { --my-var: 40px; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 30 }); +}); + +test("* supplies a non-inheriting property to every element", () => { + registerCSS(` + ${registration} + * { --my-var: 5px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + +test("* supplies a non-inheriting property at every depth", () => { + registerCSS(` + ${registration} + * { --my-var: 5px; } + .parent { opacity: 1; } + .child { width: var(--my-var); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + +test("* beats :root for the same name", () => { + registerCSS(` + ${inheritingRegistration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + * { --my-var: 5px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + +test("re-registering a stylesheet replaces the non-inheriting registry", () => { + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(true); + + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(false); +}); + +test("deleting an @property rule un-registers the property", () => { + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(true); + + registerCSS(` + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(false); +}); + +test("a re-registered property inherits again in a rendered tree", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("re-registering keeps the registry object identity", () => { + const before = nonInheritedVariables; + + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + + expect(nonInheritedVariables).toBe(before); + expect(globalThis.__react_native_css_non_inherited_variables).toBe(before); +}); + +const initialValueRegistration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 3px; + } +`; + +const initialValueConsumer = ` + .probe { width: calc(2px + var(--my-var)); } + .a { --my-var: 10px; } + .b { --my-var: 20px; } +`; + +test("a sheet registering nothing leaves the consumer no width", () => { + registerCSS(initialValueConsumer); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("deleting an @property rule retracts its initial value", () => { + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + expect(registeredInitialValues("my-var").get()).toBe(3); + + registerCSS(initialValueConsumer); + + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + +test("a mounted element drops a retracted initial value", () => { + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); + + act(() => { + registerCSS(initialValueConsumer); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("retracting an initial value keeps the observable its readers hold", () => { + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + const before = registeredInitialValues("my-var"); + + registerCSS(initialValueConsumer); + + expect(registeredInitialValues("my-var")).toBe(before); +}); + +test("resetVariableRegistries retracts a registered initial value", () => { + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + expect(registeredInitialValues("my-var").get()).toBe(3); + + resetVariableRegistries(); + + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + +test("a registration lasts for its test", () => { + registerCSS(initialValueRegistration); + + expect(nonInheritedVariables.has("my-var")).toBe(true); + expect(registeredInitialValues("my-var").get()).toBe(3); +}); + +test("a registration does not outlive its test", () => { + expect(nonInheritedVariables.size).toBe(0); + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + +test("a second copy of the module shares the non-inheriting registry", () => { + nonInheritedVariables.add("tw-ring-shadow"); + + const secondCopy = loadRootCopy(); + + expect(secondCopy.nonInheritedVariables).toBe(nonInheritedVariables); + expect(secondCopy.nonInheritedVariables.has("tw-ring-shadow")).toBe(true); +}); + +test("a second copy of the module shares the registered initial values", () => { + registerCSS(` + @property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .ring { width: calc(2px + var(--tw-ring-offset-width)); } + .offset { --tw-ring-offset-width: 4px; } + `); + + const secondCopy = loadRootCopy(); + + expect(secondCopy.registeredInitialValues).toBe(registeredInitialValues); + expect(secondCopy.registeredInitialValues("tw-ring-offset-width").get()).toBe( + 0, + ); +}); + +const tailwindRingCss = ` + @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-color { syntax: "*"; inherits: false; } + @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + + .ring-2 { + --tw-ring-shadow: 0 0 0 2px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-4 { + --tw-ring-shadow: 0 0 0 4px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-red-500 { --tw-ring-color: #fb2c36; } + .ring-blue-500 { --tw-ring-color: #2c36fb; } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 #0000001a; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } +`; + +test("a shadow-none descendant of a ringed ancestor paints no ring", () => { + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(parentTestID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + color: "#fb2c36", + }, + ], + }); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); +}); + +test("a ringed descendant of a ringed ancestor paints only its own ring", () => { + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 4, + color: "#2c36fb", + }, + ], + }); +}); + +test("a ringed descendant inherits neither the ring width nor the ring colour", () => { + // currentcolor resolves to the platform's label colour on iOS + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 4, + color: { semantic: ["label", "labelColor"] }, + }, + ], + }); +}); + +test("a ring-only element does not paint another utility's shadow", () => { + registerCSS(` + @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 #ff0000; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-2 { + --tw-ring-shadow: 0 0 0 2px #0000ff; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + color: "#00f", + }, + ], + }); +}); diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..b8db0a16 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,132 @@ test("vars", () => { color: "blue", }); }); + +const parentTestID = "parent"; + +const registration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } +`; + +const inheritingRegistration = ` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } +`; + +// An element whose rules declare no variable publishes no context, so each carrier declares an unrelated one, twice to escape inlining +const publishesVariables = ` + .has-vars { --trigger: 1px; } + .has-vars-too { --trigger: 2px; } +`; + +test("an inline vars() non-inheriting property does not reach a descendant", () => { + registerCSS(` + ${registration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("an inline vars() non-inheriting property still applies to the element carrying it", () => { + registerCSS(` + ${registration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .self { width: var(--my-var); } + `); + + render( + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an inline vars() inheriting property still reaches a descendant", () => { + registerCSS(` + ${inheritingRegistration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an inline vars() does not hand an ancestor's variable to the subtree", () => { + registerCSS(` + .ancestor { --shared: 1px; } + .ancestor-too { --shared: 2px; } + .middle { --shared: 50px; } + .middle-too { --shared: 60px; } + .child { width: var(--shared); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + +test("an inline vars() unregistered property still reaches a descendant", () => { + registerCSS(` + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..48f2c31d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -24,6 +24,7 @@ import type { StyleRuleMapping, UniqueVarInfo, } from "./compiler.types"; +import { isComputationallyIndependent } from "./computational-independence"; import { parseContainerCondition } from "./container-query"; import { parseAngle, @@ -37,6 +38,7 @@ import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; import { parseMediaQuery } from "./media-query"; +import { isUniversalSelector } from "./selector-builder"; import { StylesheetBuilder } from "./stylesheet"; import { supportsConditionValid } from "./supports"; @@ -98,8 +100,33 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { options.inlineRem = effectiveRem; } + // Collected in the first pass, since the inliner runs before the builder records registrations + const nonInheritedVariables = new Set(); + const universallyDeclaredVariables = new Set(); + const firstPassVisitor: Visitor = { Rule(rule) { + if ( + rule.type === "property" && + !rule.value.inherits && + isValidRegistration(rule.value) + ) { + nonInheritedVariables.add(rule.value.name); + } + + if ( + rule.type === "style" && + rule.value.selectors.some(isUniversalSelector) + ) { + const { declarations = [], importantDeclarations = [] } = + rule.value.declarations ?? {}; + for (const declaration of [...declarations, ...importantDeclarations]) { + if (declaration.property === "custom") { + universallyDeclaredVariables.add(declaration.value.name); + } + } + } + if ( rule.type === "unknown" && ["cssInterop", "react-native"].includes(rule.value.name) @@ -166,7 +193,14 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } }; firstPassVisitor.StyleSheetExit = (sheet) => { - return inlineVariables(sheet, vars); + // A non-inheriting property reaches every element only from a universal rule, which each element declares for itself + const runtimeVariables = new Set( + [...nonInheritedVariables].filter( + (name) => !universallyDeclaredVariables.has(name), + ), + ); + + return inlineVariables(sheet, vars, runtimeVariables); }; } @@ -421,21 +455,34 @@ function extractContainer( } } +function isValidRegistration({ initialValue }: PropertyRule): boolean { + return initialValue == null || isComputationallyIndependent(initialValue); +} + function extractPropertyRule( propertyRule: PropertyRule, builder: StylesheetBuilder, ) { - const { initialValue, name } = propertyRule; - - if (initialValue == null) { + if (!isValidRegistration(propertyRule)) { return; } + const { inherits, initialValue, name } = propertyRule; + const varName = name.startsWith("--") ? name.slice(2) : name; + + // Recorded before the early return below, as inherits is independent of initial-value + if (!inherits) { + builder.addNonInheritedVariable(varName); + } + + if (initialValue == null) { + return; + } const value = parsePropertyInitialValue(initialValue, builder); if (value !== undefined) { - builder.addRootVariable(varName, value); + builder.addRegisteredInitialValue(varName, value); } } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..e3784267 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -40,6 +40,10 @@ export interface ReactNativeCssStyleSheet { vr?: RootVariables; /** Universal Variables */ vu?: RootVariables; + /** Non-Inheriting Variables */ + vn?: string[]; + /** Registered Initial Values */ + vi?: RootVariables; } /******************************** Styles ********************************/ diff --git a/src/compiler/computational-independence.ts b/src/compiler/computational-independence.ts new file mode 100644 index 00000000..5080f209 --- /dev/null +++ b/src/compiler/computational-independence.ts @@ -0,0 +1,169 @@ +import type { + CalcFor_DimensionPercentageFor_LengthValue, + CalcFor_Length, + DimensionPercentageFor_LengthValue, + Length, + LengthUnit, + LengthValue, + MathFunctionFor_DimensionPercentageFor_LengthValue, + MathFunctionFor_Length, + ParsedComponent, + Transform, +} from "lightningcss"; + +// An @property initial-value may not depend on the element, so a font- or container-relative unit invalidates the rule (css-properties-values-api-1 ยง3) +const ELEMENT_RELATIVE_UNITS: ReadonlySet = new Set([ + "em", + "rem", + "ex", + "rex", + "ch", + "rch", + "cap", + "rcap", + "ic", + "ric", + "lh", + "rlh", + "cqw", + "cqh", + "cqi", + "cqb", + "cqmin", + "cqmax", +]); + +type LengthOperand = Length | DimensionPercentageFor_LengthValue; + +type LengthCalculation = + | CalcFor_Length + | CalcFor_DimensionPercentageFor_LengthValue; + +type LengthMathFunction = + | MathFunctionFor_Length + | MathFunctionFor_DimensionPercentageFor_LengthValue; + +export function isComputationallyIndependent( + component: ParsedComponent, +): boolean { + switch (component.type) { + case "length": + case "length-percentage": + return isIndependentOperand(component.value); + case "transform-function": + return isIndependentTransform(component.value); + case "transform-list": + return component.value.every(isIndependentTransform); + case "repeated": + return component.value.components.every(isComputationallyIndependent); + case "number": + case "integer": + case "percentage": + case "color": + case "image": + case "url": + case "angle": + case "time": + case "resolution": + case "custom-ident": + case "literal": + case "token-list": + return true; + default: + (component) satisfies never; + return false; + } +} + +function isIndependentLength(length: LengthValue): boolean { + return !ELEMENT_RELATIVE_UNITS.has(length.unit); +} + +function isIndependentOperand(operand: LengthOperand): boolean { + switch (operand.type) { + case "value": + case "dimension": + return isIndependentLength(operand.value); + case "percentage": + return true; + case "calc": + return isIndependentCalculation(operand.value); + default: + operand satisfies never; + return false; + } +} + +function isIndependentCalculation(calculation: LengthCalculation): boolean { + switch (calculation.type) { + case "number": + return true; + case "value": + return isIndependentOperand(calculation.value); + case "sum": + return calculation.value.every(isIndependentCalculation); + case "product": + return isIndependentCalculation(calculation.value[1]); + case "function": + return isIndependentMathFunction(calculation.value); + default: + calculation satisfies never; + return false; + } +} + +function isIndependentMathFunction(mathFunction: LengthMathFunction): boolean { + switch (mathFunction.type) { + case "calc": + case "abs": + case "sign": + return isIndependentCalculation(mathFunction.value); + case "min": + case "max": + case "hypot": + case "clamp": + case "rem": + case "mod": + return mathFunction.value.every(isIndependentCalculation); + case "round": + return ( + isIndependentCalculation(mathFunction.value[1]) && + isIndependentCalculation(mathFunction.value[2]) + ); + default: + mathFunction satisfies never; + return false; + } +} + +function isIndependentTransform(transform: Transform): boolean { + switch (transform.type) { + case "translate": + case "translate3d": + return transform.value.every(isIndependentOperand); + case "translateX": + case "translateY": + case "translateZ": + case "perspective": + return isIndependentOperand(transform.value); + case "scale": + case "scaleX": + case "scaleY": + case "scaleZ": + case "scale3d": + case "rotate": + case "rotateX": + case "rotateY": + case "rotateZ": + case "rotate3d": + case "skew": + case "skewX": + case "skewY": + case "matrix": + case "matrix3d": + return true; + default: + transform satisfies never; + return false; + } +} diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..395a6fd8 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -10,9 +10,10 @@ import type { UniqueVarInfo } from "./compiler.types"; export function inlineVariables( stylesheet: StyleSheet, vars: Map, + runtimeVariables: ReadonlySet, ) { for (const [name, info] of [...vars]) { - if (info.count !== 1) { + if (info.count !== 1 || runtimeVariables.has(name)) { vars.delete(name); } else { flattenVar(name, vars); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..c1da24ab 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -593,7 +593,7 @@ function isRootVariableSelector([first, ...rest]: Selector) { ); } -function isUniversalSelector([first, second]: Selector) { +export function isUniversalSelector([first, second]: Selector) { return first && first.type === "universal" && !second; } diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..d262b65d 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -63,6 +63,8 @@ export class StylesheetBuilder { ruleSets: Record; rootVariables?: VariableRecord; universalVariables?: VariableRecord; + nonInheritedVariables?: Set; + registeredInitialValues?: VariableRecord; animations?: AnimationRecord; rem: number; ruleOrder: number; @@ -173,6 +175,16 @@ export class StylesheetBuilder { ); } + if (this.shared.nonInheritedVariables?.size) { + stylesheetOptions.vn = [...this.shared.nonInheritedVariables]; + } + + if (this.shared.registeredInitialValues) { + stylesheetOptions.vi = Object.entries( + this.shared.registeredInitialValues, + ).map(([key, value]) => [key, value] as const); + } + if (this.shared.animations) { stylesheetOptions.k = Object.entries(this.shared.animations); } @@ -583,6 +595,16 @@ export class StylesheetBuilder { this.shared.rootVariables[name].push([value]); } + addNonInheritedVariable(name: string) { + this.shared.nonInheritedVariables ??= new Set(); + this.shared.nonInheritedVariables.add(name); + } + + addRegisteredInitialValue(name: string, value: StyleDescriptor) { + this.shared.registeredInitialValues ??= {}; + this.shared.registeredInitialValues[name] = [[value]]; + } + newAnimationFrames(name: string) { this.shared.animations ??= {}; diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..9647447f 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,6 +4,7 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { resetVariableRegistries } from "react-native-css/native-internal"; import { colorScheme, dimensions } from "../native/reactivity"; @@ -20,6 +21,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); + resetVariableRegistries(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..a630ab65 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -1,13 +1,20 @@ import { Platform, PlatformColor } from "react-native"; -import type { StyleDescriptor, VariableValue } from "react-native-css/compiler"; +import type { + RootVariables, + StyleDescriptor, + VariableValue, +} from "react-native-css/compiler"; import { testMediaQuery } from "../native/conditions/media-query"; import { family, observable, type Observable } from "../native/reactivity"; +// Nullable so a reload can retract a registration +type VariableArg = VariableValue[] | undefined; + const rootVariableFamily = () => { - return family>(() => { - const obs = observable( + return family>(() => { + const obs = observable( (read, variableValue) => { if (!variableValue) return undefined; @@ -32,13 +39,75 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); -rootVariables("__rn-css-rem").set([[14]]); -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -rootVariables("__rn-css-color").set([ - [ - Platform.OS === "ios" - ? PlatformColor("label", "labelColor") - : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -] as any); +declare global { + var __react_native_css_registered_initial_values: + | ReturnType + | undefined; + var __react_native_css_non_inherited_variables: Set | undefined; +} + +// Pinned to globalThis, since Metro can load two copies of this module and StyleCollection injects through one +globalThis.__react_native_css_registered_initial_values ??= + rootVariableFamily(); +globalThis.__react_native_css_non_inherited_variables ??= new Set(); + +export const registeredInitialValues = + globalThis.__react_native_css_registered_initial_values; + +export const nonInheritedVariables = + globalThis.__react_native_css_non_inherited_variables; + +// The inherit flag belongs to the registration, so every channel that publishes variables goes through here +export function assignInheritedVariables( + target: Record, + entries: Iterable, +) { + for (const [name, value] of entries) { + if (nonInheritedVariables.has(name)) { + continue; + } + + target[name] = value; + } +} + +// Retracted through set(undefined), since clearing the family would strand the observables mounted elements read +export function replaceRegisteredInitialValues(entries: RootVariables = []) { + const registered = new Set(entries.map(([name]) => name)); + + // Snapshotted, since a retraction can notify a reader that looks up a new name mid-walk + for (const name of Array.from(registeredInitialValues.keys())) { + if (!registered.has(name)) { + registeredInitialValues(name).set(undefined); + } + } + + for (const [name, value] of entries) { + registeredInitialValues(name).set(value); + } +} + +function seedRootVariables() { + rootVariables("__rn-css-rem").set([[14]]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + rootVariables("__rn-css-color").set([ + [ + Platform.OS === "ios" + ? PlatformColor("label", "labelColor") + : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); +} + +seedRootVariables(); + +// Clearing suffices between tests, where nothing stays mounted to strand +export function resetVariableRegistries() { + rootVariables.clear(); + universalVariables.clear(); + registeredInitialValues.clear(); + nonInheritedVariables.clear(); + + seedRootVariables(); +} diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index 21a296a6..af929de6 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -14,9 +14,20 @@ import { type Observable, type VariableContextValue, } from "../native/reactivity"; -import { rootVariables, universalVariables } from "./root"; - -export { rootVariables, universalVariables }; +import { + nonInheritedVariables, + registeredInitialValues, + replaceRegisteredInitialValues, + rootVariables, + universalVariables, +} from "./root"; + +export { + nonInheritedVariables, + registeredInitialValues, + rootVariables, + universalVariables, +}; interface StyleCollectionType { styles: ReturnType>>; @@ -95,6 +106,18 @@ globalThis.__react_native_css_style_collection ??= { } } + // A reload replaces both halves of every registration, or a deleted @property keeps its initial value + replaceRegisteredInitialValues(options.vi); + + // Cleared in place, since a second copy of root.ts reads this same Set + nonInheritedVariables.clear(); + + if (options.vn) { + for (const name of options.vn) { + nonInheritedVariables.add(name); + } + } + for (const effect of observableBatch.current) { effect.run(); } diff --git a/src/native-internal/variables.tsx b/src/native-internal/variables.tsx index 8a0f81e0..031d117d 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -8,6 +8,7 @@ import { import type { StyleDescriptor } from "react-native-css/compiler"; import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import { assignInheritedVariables } from "./root"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -21,16 +22,21 @@ export function VariableContextProvider( ) { const inheritedVariables = useContext(VariableContext); - const value: VariableContextValue = useMemo( - () => ({ + const value: VariableContextValue = useMemo(() => { + const published: VariableContextValue = { ...inheritedVariables, - ...Object.fromEntries( - Object.entries(props.value).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), [VAR_SYMBOL]: true, - }), - [inheritedVariables, props.value], - ); + }; + + assignInheritedVariables( + published, + Object.entries(props.value).map( + ([name, value]) => [name.replace(/^--/, ""), value] as const, + ), + ); + + return published; + }, [inheritedVariables, props.value]); return {props.children}; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..4dde874c 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,6 +1,9 @@ /* eslint-disable */ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; -import { StyleCollection } from "react-native-css/native-internal"; +import { + assignInheritedVariables, + StyleCollection, +} from "react-native-css/native-internal"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; @@ -131,13 +134,12 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { + if (variables === undefined || variables === inheritedVariables) { variables = { ...inheritedVariables }; } - for (const v of rule.v) { - variables![v[0]] = v[1]; - } + // Published to descendants; the declaring element resolves its own var() from the rule + assignInheritedVariables(variables, rule.v); } if (rule.c) { @@ -224,11 +226,12 @@ export function updateRules( rules.add(inheritedVariables); if (inlineVariables.size) { + // An inline vars() applies in full to its own element, and descendants inherit only what each registration lets through variables = Object.assign( {}, inheritedVariables, variables, - ...Array.from(inlineVariables), + ...Array.from(inlineVariables, publishableVariables), { [VAR_SYMBOL]: true }, ); } @@ -265,6 +268,12 @@ export function updateRules( }; } +function publishableVariables(inlineVariable: InlineVariable): InlineVariable { + const published: InlineVariable = { [VAR_SYMBOL]: "inline" }; + assignInheritedVariables(published, Object.entries(inlineVariable)); + return published; +} + /** * Create variations of a style rule based on the config. * Cache for reference equality. diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..773d2440 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -154,6 +154,9 @@ export function family( delete(key: Key) { return map.delete(key); }, + keys() { + return map.keys(); + }, clear() { return map.clear(); }, diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 0b785645..9c66be0d 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -1,5 +1,7 @@ import type { StyleDescriptor, StyleFunction } from "react-native-css/compiler"; import { + nonInheritedVariables, + registeredInitialValues, rootVariables, universalVariables, } from "react-native-css/native-internal"; @@ -83,7 +85,19 @@ export function varResolver( return variableCycles.has(name) ? resolve(fallback) : value; } - value = resolve(get(rootVariables(name))); + // :root reaches other elements only by inheritance, which a non-inheriting registration switches off + if (!nonInheritedVariables.has(name)) { + value = resolve(get(rootVariables(name))); + if (variableCycles.has(name)) return resolve(fallback); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + } + } + + // A registered initial value applies only where no declaration reaches the element + value = resolve(get(registeredInitialValues(name))); if (variableCycles.has(name)) return resolve(fallback); if (value !== undefined) { options.inlineVariables ??= { [VAR_SYMBOL]: "inline" };