diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 119c85d7..6bafd490 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,18 @@ 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", () => { + // A registered initial value is not a declaration on any element. :root's are values + // the root element HAS and descendants inherit, and *'s are declared on each element; + // this is what the property resolves to where nothing declares it. Sharing vr let + // source order pick between a :root declaration and the default, and left a + // non-inheriting property no way to reach its default once :root was skipped const compiled = compile(` @property --tw-shadow { syntax: "*"; @@ -87,10 +93,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 +162,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 +179,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 +205,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 +223,199 @@ 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(); + // --tw-ring-color has no initial value, so it publishes no registered default + 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", () => { + // Derived rather than restated: the expected set is read off the source CSS, so a + // property added to the fixture is covered without touching the assertion + 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("@property without an inherits descriptor never reaches the registry", () => { + // syntax and inherits are both required; a rule missing either is invalid + // (css-properties-values-api-1). The spec has the invalid rule ignored, while + // lightningcss rejects the whole sheet — either way no half-registration exists for + // the inherit flag to be guessed from, which is the property this pins + expect(() => + compile(` +@property --no-descriptor { + syntax: ""; + initial-value: 0px; +} +`), + ).toThrow("Invalid @ rule body"); +}); + +test("a non-inheriting property is left to the runtime, however few rules declare it", () => { + // The inliner folds a property with one declaration into its consumers, which answers + // for every element the consumer matches. That is sound only while the value reaches + // all of them, and a non-inheriting property reaches the declaring element alone + 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"); + + // The declaration survives as a rule, and the consumer still holds a var() call + 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", () => { + // Custom properties inherit by default; only an @property rule can opt out. + // Declared twice on purpose: with one definition the inliner erases it and the + // compiled output is empty, so the assertion would hold for a stylesheet + // containing nothing at all. + const compiled = compile(` +.my-class { --my-var: 10px; } +.other { --my-var: 20px; } +`); + + const result = compiled.stylesheet(); + expect(result.s).toBeDefined(); + expect(result.vn).toBeUndefined(); + // Neither half of a registration is emitted. This is the sheet a Fast Refresh produces + // when an @property rule is deleted, so it is what the runtime has to retract AGAINST + expect(result.vi).toBeUndefined(); +}); + +test("@property records a name once, however many rules declare it", () => { + // Different syntaxes on purpose — lightningcss collapses identical @property + // blocks before the visitor sees them, so an identical pair would not reach + // the Set that does the deduplicating. + 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 inside @media is not recorded — a known limitation", () => { + // lightningcss reports the nested rule as type "unknown" and extractRule drops it, + // so neither vn nor vi is emitted. Pinned in both directions: the day extractRule + // learns about a nested @property, vn has to follow it. + const compiled = compile(` +@media (min-width: 100px) { + @property --scoped { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } +} +`); + + const result = compiled.stylesheet(); + expect(result.vn).toBeUndefined(); + expect(result.vi).toBeUndefined(); +}); + test("@property with repeated multi-child preserves array", () => { const compiled = compile(` @property --my-offsets { @@ -218,9 +426,9 @@ 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]]]); }); diff --git a/src/__tests__/native/non-inheriting-channels.test.tsx b/src/__tests__/native/non-inheriting-channels.test.tsx new file mode 100644 index 00000000..60fbab9c --- /dev/null +++ b/src/__tests__/native/non-inheriting-channels.test.tsx @@ -0,0 +1,549 @@ +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; + } +`; + +// Most custom properties below are declared twice. A property with a single definition is +// folded into its consumers by the compile-time inliner, so a runtime assertion over one +// would be measuring the compiler. The tests that name the inliner as their subject say so + +/* ------------------------------------------------------------------ * + * Channel 1 — VariableContextProvider + * ------------------------------------------------------------------ */ + +test("VariableContextProvider does not publish a non-inheriting property", () => { + // Web renders this provider as a real
, so the browser's + // own cascade withholds a non-inheriting property from the descendant. Native has to + // reach the same answer or the two platforms disagree on the rule this feature IS + 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, + }); +}); + +/* ------------------------------------------------------------------ * + * Channel 3 — :root, and the compile-time inliner behind it + * ------------------------------------------------------------------ */ + +test(":root does not supply a non-inheriting property to a descendant", () => { + // :root declares the property on the root element. Every other element gets it by + // INHERITANCE, which is exactly what the registration switches off. The value the + // descendant must see is the registered initial value + registerCSS(` + ${registration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test(":root does not supply a non-inheriting property, whichever order it is declared in", () => { + // The two values share one rootVariables slot, so the winner is decided by source + // order. Tailwind emits @property first and :root after, which is the losing order + registerCSS(` + :root { --my-var: 50px; } + :root { --my-var: 50px; } + ${registration} + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test(":root with a single declaration does not supply a non-inheriting property", () => { + // A property declared once is folded into its consumers by the compile-time inliner, + // which resolves it before any registry exists. A runtime-only fix cannot reach this + registerCSS(` + ${registration} + :root { --my-var: 50px; } + .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", () => { + // The counterpart to the test above: blocking the inliner must not cost the declaring + // element its own value, which now has to resolve at runtime instead of at compile time + 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 a descendant", () => { + 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", () => { + // Both are values of the same name, but one is a DECLARATION and the other is the + // property's default. A declaration wins, wherever the @property block happens to sit + 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 }); +}); + +/* ------------------------------------------------------------------ * + * The universal selector declares, it does not hand down + * ------------------------------------------------------------------ */ + +test("* supplies a non-inheriting property to every element", () => { + // `*` matches each element in its own right, so each one DECLARES the property and + // the registration never comes into it. This is the rung :root is skipped for + 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", () => { + // A declaration on the element beats a value inherited from the root + 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 }); +}); + +/* ------------------------------------------------------------------ * + * Lifecycle — re-registration replaces, it does not accumulate + * ------------------------------------------------------------------ */ + +test("re-registering a stylesheet replaces the non-inheriting registry", () => { + // Fast Refresh re-injects the whole stylesheet. Editing `inherits: false` to `true` has + // to take effect; an append-only registry pins the property non-inheriting for the rest + // of the session and only a full reload clears it + 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", () => { + // The globalThis pin exists so two copies of native-internal/root share ONE container. + // A reload replaces the container's CONTENTS; swapping the container itself would hand + // the other copy a Set nothing writes to any more + 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); +}); + +/* ------------------------------------------------------------------ * + * Lifecycle — a reload retracts a registered initial value + * ------------------------------------------------------------------ */ + +// Composed into arithmetic ON THE DECLARING ELEMENT, which is how Tailwind reads +// `--tw-ring-offset-width`: `calc(2px + var(--tw-ring-offset-width))`. A registration that +// outlives the rule declaring it does not hand a descendant something it should not have +// inherited, it corrupts a length an element computes for itself +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", () => { + // The control for the two tests below. `.probe` reads a property no rule it matches + // declares and no @property registers, so the whole declaration drops + 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", () => { + // Deleting an @property rule un-registered only half of it: the name left + // `nonInheritedVariables` and the initial value stayed, so the two halves of one + // registration disagreed and the element kept painting a width the sheet no longer + // declares anywhere + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); + + act(() => { + registerCSS(initialValueConsumer); + }); + + // Exactly what the same sheet paints when it is the first one loaded, two tests above + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("retracting an initial value keeps the observable its readers hold", () => { + // This is why the retraction is `.set(undefined)` and not `.clear()`. Clearing the family + // drops the map entry without notifying anyone, so a mounted reader keeps the deleted + // value AND the next registration of the same name lands on an observable it never + // subscribed to — the reader is then stranded for the rest of the session + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + const before = registeredInitialValues("my-var"); + + registerCSS(initialValueConsumer); + + expect(registeredInitialValues("my-var")).toBe(before); +}); + +test("resetVariableRegistries retracts a registered initial value", () => { + // The jest preset's beforeEach is all that stands between one test's @property + // registration and the next test's. A plain clear() is right HERE and wrong in inject(): + // testing-library unmounts between tests, so this retraction has no reader to strand + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + expect(registeredInitialValues("my-var").get()).toBe(3); + + resetVariableRegistries(); + + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + +/* ------------------------------------------------------------------ * + * Tailwind v4 ring composition, end to end + * ------------------------------------------------------------------ */ + +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", () => { + // The device report this whole feature comes from: on an Android handset a shadow-none + // descendant painted its ancestor's ring, because every Tailwind shadow-* utility + // composes var(--tw-ring-shadow) and that variable used to inherit + 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", () => { + // ring-4 with no ring colour of its own must reach its own currentcolor fallback, NOT + // the ancestor's red. Two variables, two independent leaks, one assertion. + // currentcolor resolves to the platform's text colour, as in filters.test.tsx + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 4, + color: { semantic: ["label", "labelColor"] }, + }, + ], + }); +}); diff --git a/src/__tests__/native/non-inheriting-registry.test.ts b/src/__tests__/native/non-inheriting-registry.test.ts new file mode 100644 index 00000000..9b3dc0fc --- /dev/null +++ b/src/__tests__/native/non-inheriting-registry.test.ts @@ -0,0 +1,69 @@ +import { registerCSS } from "react-native-css/jest"; + +import { + nonInheritedVariables, + registeredInitialValues, +} from "../../native-internal/root"; + +// jest.resetModules() gives a fresh module registry against the same globalThis, which is +// exactly the dual package case: the exports map splits import and require onto different +// builds and Metro resolves that per requesting module, so two copies of +// native-internal/root evaluate in one bundle. StyleCollection is globalThis-pinned, so +// whichever copy wins does all the injecting and fills ITS registries + +test("a second copy of the module shares the non-inheriting registry", async () => { + // If this one were module-scoped, a rules.ts bound to the other copy would read an empty + // Set and the filter would never fire — the ring leaks again, with nothing to indicate why + const firstCopy = await import("../../native-internal/root"); + firstCopy.nonInheritedVariables.add("tw-ring-shadow"); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + // The module body really re-ran, so the assertion below is about two copies + expect(secondCopy).not.toBe(firstCopy); + + 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", async () => { + const firstCopy = await import("../../native-internal/root"); + firstCopy.registeredInitialValues("tw-ring-offset-width").set([[0]]); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + expect(secondCopy).not.toBe(firstCopy); + + expect(secondCopy.registeredInitialValues).toBe(registeredInitialValues); + expect(secondCopy.registeredInitialValues("tw-ring-offset-width").get()).toBe( + 0, + ); +}); + +test("an @property initial value injected through one copy resolves in the other", async () => { + // The registered initial value is not a fallback the resolver can do without. Tailwind + // composes `--tw-ring-offset-width` into a length — `calc(2px + var(--tw-ring-offset-width))` + // — ON THE ELEMENT THAT DECLARES THE RING, so a copy reading an empty registry does not + // lose an inherited value it was never entitled to, it corrupts an arithmetic result the + // declaring element computes for itself + 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; } + `); + + expect(registeredInitialValues("tw-ring-offset-width").get()).toBe(0); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + expect(secondCopy.registeredInitialValues("tw-ring-offset-width").get()).toBe( + 0, + ); +}); 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..12819e9a --- /dev/null +++ b/src/__tests__/native/non-inheriting-variables.test.tsx @@ -0,0 +1,223 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +const parentTestID = "parent"; + +// Every custom property below is declared twice. A property with a single definition is +// inlined into its consumers at compile time and never reaches the runtime path under test +test("a non-inheriting custom property does not reach a descendant", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + // The child resolves the registered initial value, not the ancestor's 10px + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("an inheriting custom property still reaches a descendant", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } + .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", () => { + // No @property rule, so the CSS default applies: custom properties inherit. + 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 non-inheriting custom property still applies to the element declaring it", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .self { --my-var: 10px; width: var(--my-var); } + .other { --my-var: 20px; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +// Every Tailwind v4 shadow-* utility composes var(--tw-ring-shadow), shadow-none included +test("an ancestor's ring does not reach a descendant's box-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; } + @property --tw-ring-color { syntax: "*"; inherits: false; } + + .ring-2 { + --tw-ring-shadow: 0 0 0 2px var(--tw-ring-color, currentcolor); + box-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-shadow), var(--tw-shadow); + } + .ring-red { --tw-ring-color: #fb2c36; } + .ring-blue { --tw-ring-color: #2c36fb; } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 #0000001a; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + `); + + render( + + + , + ); + + // The ancestor paints its own ring. + expect(screen.getByTestId(parentTestID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + color: "#fb2c36", + }, + ], + }); + + // The descendant paints nothing, as every layer it composes is transparent + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); +}); + +test("a non-inheriting custom property does not reach a grandchild", () => { + // Distinguishes "withheld one level" from "withheld entirely". An implementation + // that only blanked the immediate child would pass every test above. + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .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("a descendant falls through to the var() fallback when there is no initial value", () => { + // The --tw-ring-color shape: registered non-inheriting with no default. The compiler + // records it but publishes no root variable, so the descendant must reach its fallback + // rather than resolving undefined. + 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("a descendant declaring the property itself wins over the ancestor", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .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 name registered by an earlier test does not leak into this one", () => { + // Names the jest reset as its own subject. Without it this guarantee rests on the + // tests above happening to reuse --my-var and happening to run first. + registerCSS(` + .parent { --leaky: 10px; } + .other { --leaky: 20px; } + .child { width: var(--leaky); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..46ed287f 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,146 @@ 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; + } +`; + +// Each custom property is declared twice so the compile-time inliner cannot fold it, +// which is what puts the runtime path under test. +// +// The carrier also has a className declaring an UNRELATED variable. An element whose +// rules declare no variable at all publishes no context, so its inline vars() reach no +// descendant either way and the assertion below would hold without the property registry +// having been consulted at all +const publishesVariables = ` + .has-vars { --trigger: 1px; } + .has-vars-too { --trigger: 2px; } +`; + +test("an inline vars() non-inheriting property does not reach a descendant", () => { + // css-properties-values-api-1: the inherit flag belongs to the REGISTRATION, not to the + // declaration. An inline declaration wins the cascade on the element it sits on; it + // cannot make a non-inherited property inherit. Web agrees, because vars() there is a + // plain inline custom-property declaration handed straight to the browser + 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", () => { + // The other half of the rule: withheld from descendants, honoured on the element itself + 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", () => { + // The published object merged the inherited bag OVER the element's own, so carrying + // any inline vars() — even one naming an unrelated variable — replaced every value + // the element declared with its ancestor's + 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 214cd615..b6b8df8e 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -114,6 +114,19 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { if (options.inlineVariables !== false) { const exclusionList: string[] = options.inlineVariables?.exclude ?? []; + // The inliner runs in this pass, so the registrations it has to respect are collected + // here rather than read off the builder, which the second pass fills. lightningcss + // keeps only the last @property rule per name, so this sees the winning declaration + const nonInheritedVariables = new Set(); + + firstPassVisitor.Rule = (rule) => { + if (rule.type === "property" && !rule.value.inherits) { + nonInheritedVariables.add(rule.value.name); + } + + return rule; + }; + firstPassVisitor.Declaration = (decl) => { if ( decl.property === "custom" && @@ -132,7 +145,7 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } }; firstPassVisitor.StyleSheetExit = (sheet) => { - return inlineVariables(sheet, vars); + return inlineVariables(sheet, vars, nonInheritedVariables); }; } @@ -406,17 +419,22 @@ function extractPropertyRule( propertyRule: PropertyRule, builder: StylesheetBuilder, ) { - const { initialValue, name } = propertyRule; + 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 varName = name.startsWith("--") ? name.slice(2) : name; 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..f6c02824 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -40,6 +40,18 @@ export interface ReactNativeCssStyleSheet { vr?: RootVariables; /** Universal Variables */ vu?: RootVariables; + /** Non-Inheriting Variables */ + vn?: string[]; + /** + * Registered Initial Values — the `initial-value` of an `@property` rule. + * + * Not a declaration on any element, which is why it is not in `vr`: a `:root` + * declaration is a value the root element HAS and descendants inherit, while this is + * the value a property TAKES on an element that declares it nowhere. Sharing one slot + * let source order decide between them, and left a non-inheriting property no way to + * reach its default once `:root` was skipped + */ + vi?: RootVariables; } /******************************** Styles ********************************/ diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..5b68f84a 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -10,9 +10,14 @@ import type { UniqueVarInfo } from "./compiler.types"; export function inlineVariables( stylesheet: StyleSheet, vars: Map, + nonInheritedVariables: ReadonlySet, ) { for (const [name, info] of [...vars]) { - if (info.count !== 1) { + // Folding a single declaration into its consumers answers for every element the + // consumer matches, which is sound only while the value reaches all of them. A + // property registered `inherits: false` reaches the declaring element and nothing + // below it, so a consumer in another rule must resolve it at runtime instead + if (info.count !== 1 || nonInheritedVariables.has(name)) { vars.delete(name); } else { flattenVar(name, vars); diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..9487aa79 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,20 @@ export class StylesheetBuilder { this.shared.rootVariables[name].push([value]); } + addNonInheritedVariable(name: string) { + this.shared.nonInheritedVariables ??= new Set(); + this.shared.nonInheritedVariables.add(name); + } + + /** + * A registration carries exactly one initial value, so this assigns where + * addRootVariable pushes — there is no list of candidates to pick from. + */ + 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 cc125390..547d9d8d 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(null); colorScheme.set(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..ca85d5d5 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -1,13 +1,21 @@ 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"; +// The argument is nullable because a reload has to be able to RETRACT a name, and the read +// below already answers `undefined` for one — see replaceRegisteredInitialValues +type VariableArg = VariableValue[] | undefined; + const rootVariableFamily = () => { - return family>(() => { - const obs = observable( + return family>(() => { + const obs = observable( (read, variableValue) => { if (!variableValue) return undefined; @@ -32,13 +40,126 @@ 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; +} + +// Both pinned to globalThis like style-collection.ts and variables.tsx. The exports map +// splits import and require onto different builds and Metro resolves that per requesting +// module, so two copies of this file can load. StyleCollection is globalThis-pinned, so +// whichever copy wins it does all the injecting and fills ITS containers — the other copy +// reads a Set whose filter never fires, and a registry that answers undefined for every +// registration. Neither has a seed to protect, so the plain `??=` is the whole guard. +globalThis.__react_native_css_registered_initial_values ??= + rootVariableFamily(); +globalThis.__react_native_css_non_inherited_variables ??= new Set(); + +/** + * The `initial-value` of an `@property` rule: what a custom property resolves to on an + * element that declares it nowhere. Separate from rootVariables because a `:root` + * declaration is a value the root element HAS and descendants read by inheritance, which + * is the one thing a non-inheriting property never does. + * + * A registration carries a single value, so each entry holds one — the family shape is + * shared with the other two so a re-injected stylesheet notifies its readers. + * + * Losing this across a copy is not a missing fallback. Tailwind composes a registered + * width into arithmetic on the element that DECLARES it — `calc(2px + + * var(--tw-ring-offset-width))` — so an empty registry corrupts a length the declaring + * element computes for itself, with no ancestor involved. + */ +export const registeredInitialValues = + globalThis.__react_native_css_registered_initial_values; + +export const nonInheritedVariables = + globalThis.__react_native_css_non_inherited_variables; + +/** + * Copy the custom properties an element publishes to its descendants. A property + * registered `inherits: false` is withheld, so the descendant resolves the registered + * initial value rather than the ancestor's. Every channel that builds a VariableContext + * goes through here — a stylesheet rule, an inline `vars()`, a VariableContextProvider — + * because the inherit flag belongs to the registration, not to the declaration that set it + */ +export function assignInheritedVariables( + target: Record, + entries: Iterable, +) { + for (const [name, value] of entries) { + if (nonInheritedVariables.has(name)) { + continue; + } + + target[name] = value; + } +} + +/** + * Replace every registered initial value with the ones a stylesheet carries. + * + * A reload has to be able to DELETE an `@property` rule, and this registry is observable, + * so dropping the entry is not enough. `family.clear()` is a `Map.clear()`, which notifies + * nobody: a mounted element keeps painting the deleted registration's value, and the next + * registration of that name lands on a fresh observable that element never subscribed to. + * Retracting through `set(undefined)` takes the same notification path a changed value + * takes, and leaves the observable its readers already hold in place. + * + * `resetVariableRegistries` below still clears, because nothing is mounted across the test + * boundary it serves — the mechanism differs where the readers do. + */ +export function replaceRegisteredInitialValues(entries: RootVariables = []) { + const registered = new Set(entries.map(([name]) => name)); + + // Snapshotted because the family creates an entry for every name the resolver LOOKS UP, + // so a retraction outside a batch can notify a reader that resolves a new one mid-walk. + // Retracting a name that carries no registration is already a no-op: the observable + // recomputes to the `undefined` it holds, compares equal, and notifies nobody + 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(); + +/** + * Return every variable registry to its boot state, seeds included. + * + * A reload replaces the two registries an `@property` rule writes, but `:root` and `*` + * declarations are overwrite-only — a name the new sheet drops keeps the value the previous + * one gave it. That is what a reload should do to a running app and the opposite of what one + * test should do to the next, and a test that injects no stylesheet at all needs the reset + * either way. + * + * Clearing is enough here, where `inject` has to retract through `set(undefined)`: nothing + * is mounted across the boundary this serves, so there is no reader 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 eff34009..efd8aadf 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>>; @@ -89,9 +100,29 @@ globalThis.__react_native_css_style_collection ??= { } } + // `* { --x }` declares the property ON each element, which is the rung varResolver + // reads before rootVariables and the one a registration cannot switch off if (options.vu) { for (const entry of options.vu) { - rootVariables(entry[0]).set(entry[1]); + universalVariables(entry[0]).set(entry[1]); + } + } + + // A stylesheet reload REPLACES the registrations it carries — editing an @property + // rule to `inherits: true`, or deleting it, has to take effect. Both halves of a + // registration are replaced, or the two disagree: the name leaves the Set below while + // its initial value stays behind, and an element goes on painting a length no rule in + // the sheet declares. Retracting an observed value is not a clear() — see root.ts + replaceRegisteredInitialValues(options.vi); + + // The container itself is kept, because the globalThis pin exists so a second copy of + // root.ts shares this exact Set; swapping it would leave that copy reading one nothing + // writes to + nonInheritedVariables.clear(); + + if (options.vn) { + for (const name of options.vn) { + nonInheritedVariables.add(name); } } 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 f85a66f9..adb7ff42 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"; @@ -129,13 +132,14 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { + // We're going to set a value, so we need to create a new object + if (variables === undefined || variables === inheritedVariables) { variables = { ...inheritedVariables }; } - for (const v of rule.v) { - variables![v[0]] = v[1]; - } + // These are the variables published to descendants. The declaring element + // still resolves its own var() from the rule, in calculateProps + assignInheritedVariables(variables, rule.v); } if (rule.c) { @@ -216,11 +220,20 @@ export function updateRules( rules.add(inheritedVariables); if (inlineVariables.size) { + // An inline vars() declaration wins the cascade on the element it sits on, but + // cannot make a non-inherited property inherit — the inherit flag belongs to the + // @property registration, not to the declaration. So the element's own bag keeps + // every name (it is added to `rules` below, for calculateProps) while the copy + // published to descendants goes through the same filter as a stylesheet rule + // `variables` already carries the inherited bag under the element's own values, so + // it goes second — merging the ancestor's over it would undo every declaration the + // element made. It is undefined when a rule reads a variable without declaring one, + // which is why the inherited bag is still listed variables = Object.assign( {}, - variables, inheritedVariables, - ...Array.from(inlineVariables), + variables, + ...Array.from(inlineVariables, publishableVariables), { [VAR_SYMBOL]: true }, ); } @@ -257,6 +270,15 @@ export function updateRules( }; } +/** + * The subset of an inline `vars()` object that descendants inherit. + */ +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 0824edeb..c76552b8 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -140,6 +140,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 af3d6ee2..1e99bd00 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"; @@ -77,7 +79,20 @@ export function varResolver( return value; } - value = resolve(get(rootVariables(name))); + // :root declares the property on the root element and every other element reads it by + // inheritance, so a registration that switches inheritance off skips this rung. The + // universal rung above stays: `* { --x }` declares the property ON each element + if (!nonInheritedVariables.has(name)) { + value = resolve(get(rootVariables(name))); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + } + } + + // Last, because a declaration anywhere above beats the property's own default + value = resolve(get(registeredInitialValues(name))); if (value !== undefined) { options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; options.inlineVariables[name] = value;