From ca6f630fdecfee761fc29e87265a7e8a3db8ea82 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:44:05 +0300 Subject: [PATCH] fix(native): a custom property with no value produces no key vars() and mapped every entry of the record they are given into the runtime's variable record, so an entry whose value is undefined planted a key holding undefined. Variable lookup is presence-keyed, so that key read as "set to nothing" and stopped the cascade before the inherited value, the :root value and the var() fallback. Both channels now build the record through toVariableRecord, which strips the -- prefix and skips an entry with no value, the invariant the compiler already holds. "unset" remains how a variable is deliberately cleared. --- src/__tests__/native/variables.test.tsx | 82 ++++++++++++++++++++++++- src/__tests__/native/vars.test.tsx | 12 ++++ src/native-internal/variables.tsx | 10 +-- src/native/api.tsx | 5 +- src/native/reactivity.ts | 15 +++++ 5 files changed, 116 insertions(+), 8 deletions(-) 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 6736f746..a83d997c 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -18,6 +18,7 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + toVariableRecord, VAR_SYMBOL, type Effect, type Getter, @@ -123,8 +124,6 @@ export function useNativeVariable(name: string) { export function vars(variables: Record): ViewStyle { return Object.assign( { [VAR_SYMBOL]: "inline" }, - Object.fromEntries( - Object.entries(variables).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), + toVariableRecord(variables), ) as ViewStyle; } diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..70326490 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -198,6 +198,21 @@ export type VariableContextValue = Record & { [VAR_SYMBOL]: true; }; +// Variable lookup is presence-keyed, so an entry with no value produces no key rather than one that stops the cascade +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));