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));