diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..25ac30f5 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -2,9 +2,12 @@ import { memo, useEffect } from "react"; import type { ViewProps } from "react-native"; import { render, screen } from "@testing-library/react-native"; -import { styled, VariableContextProvider } from "react-native-css"; +import { styled } from "react-native-css"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +// `react-native-css` is the web surface to TypeScript, so the native +// `VariableContextProvider` signature is reached through `/native`. +import { VariableContextProvider } from "react-native-css/native"; test("inline variable", () => { registerCSS(`.my-class { width: var(--my-var); --my-var: 10px; }`); @@ -242,6 +245,83 @@ test("VariableContextProvider", () => { expect(component.props.style).toStrictEqual({ color: "red" }); }); +test("VariableContextProvider ignores an undefined value", () => { + // Two definitions, so the compiler cannot inline `--my-var` and the value is + // read at runtime. A single definition is folded into the declaration and + // never reaches the variable record this test is about. + registerCSS(` + .other { --my-var: blue; } + .other-2 { --my-var: purple; } + .test { color: var(--my-var); } + `); + + render( + + + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "red" }); +}); + +test("VariableContextProvider with an undefined value falls through to :root", () => { + registerCSS(` + :root { --my-var: green; } + .other { --my-var: blue; } + .test { color: var(--my-var); } + `); + + render( + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "green" }); +}); + +test("VariableContextProvider with an undefined value leaves the var() fallback reachable", () => { + // Nothing sets `--my-var` on the ancestor chain, so the fallback is the only + // value `.test` can reach. + registerCSS(` + .other { --my-var: blue; } + .other-2 { --my-var: purple; } + .test { color: var(--my-var, green); } + `); + + render( + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "green" }); +}); + +test("VariableContextProvider clears a variable with unset", () => { + registerCSS(` + :root { --my-var: green; } + .other { --my-var: blue; } + .test { color: var(--my-var); } + `); + + render( + + + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: undefined }); +}); + test("variable overriding with classes", () => { registerCSS(` :root { diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..a3515b98 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -4,6 +4,11 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { vars } from "react-native-css/runtime"; +// `vars()` is platform-split and `react-native-css/runtime` is its web half to +// TypeScript. The case below is about what the native implementation stores, so +// it reaches that implementation directly, as units.test.tsx does. +import { vars as nativeVars } from "../../native/api"; + test("vars", () => { registerCSS( `.my-class { @@ -36,3 +41,10 @@ test("vars", () => { color: "blue", }); }); + +test("vars does not create a key for an undefined value", () => { + const inline = nativeVars({ "--defined": "red", "--absent": undefined }); + + expect(Object.keys(inline)).toStrictEqual(["defined"]); + expect("absent" in inline).toBe(false); +}); diff --git a/src/native-internal/variables.tsx b/src/native-internal/variables.tsx index 8a0f81e0..322d3c35 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -7,7 +7,11 @@ import { import type { StyleDescriptor } from "react-native-css/compiler"; -import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import { + toVariableRecord, + VAR_SYMBOL, + type VariableContextValue, +} from "../native/reactivity"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -24,9 +28,7 @@ export function VariableContextProvider( const value: VariableContextValue = useMemo( () => ({ ...inheritedVariables, - ...Object.fromEntries( - Object.entries(props.value).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), + ...toVariableRecord(props.value), [VAR_SYMBOL]: true, }), [inheritedVariables, props.value], diff --git a/src/native/api.tsx b/src/native/api.tsx index 3d68a3aa..52b4a89b 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -16,6 +16,7 @@ import { mappingToConfig, useNativeCss } from "./react/useNativeCss"; import { usePassthrough } from "./react/usePassthrough"; import { colorScheme as colorSchemeObs, + toVariableRecord, VAR_SYMBOL, type Effect, type Getter, @@ -115,10 +116,5 @@ export function useNativeVariable(name: string) { * @deprecated Use `` instead. */ export function vars(variables: Record) { - return Object.assign( - { [VAR_SYMBOL]: "inline" }, - Object.fromEntries( - Object.entries(variables).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), - ); + return Object.assign({ [VAR_SYMBOL]: "inline" }, toVariableRecord(variables)); } diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..898dfbc2 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -186,6 +186,31 @@ export type VariableContextValue = Record & { [VAR_SYMBOL]: true; }; +/** + * Normalises the `Record<"--name", value>` shape the two JavaScript channels + * into the variable system accept - `vars()` and `` - + * into the unprefixed record the runtime stores. + * + * An entry with no value produces no key. Variable lookup is presence-keyed + * (`name in variables`), so a key holding `undefined` reads as "this variable + * is set to nothing" and stops the cascade before the inherited value, the + * `:root` value and the `var()` fallback. Absence is how a record spells "no + * value"; `"unset"` is how a variable is deliberately cleared. + */ +export function toVariableRecord( + variables: Record, +): Record { + const record: Record = {}; + + for (const [name, value] of Object.entries(variables)) { + if (value !== undefined) { + record[name.replace(/^--/, "")] = value; + } + } + + return record; +} + /** Pseudo Classes ************************************************************/ export const hoverFamily = weakFamily(() => observable(false));