From ca6f630fdecfee761fc29e87265a7e8a3db8ea82 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:44:05 +0300 Subject: [PATCH 1/3] 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)); From 84c1f23d7b26986f5b3bc023bd44b5d66e1f13fa Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 02:12:14 +0300 Subject: [PATCH 2/3] fix(types): type CSS custom properties against what both platforms honour vars() and VariableContextProvider were typed Record on web and Record on native, and a consumer only sees the web declarations, since the root entry's types re-export ./web. A font-family list or an undefined entry was a type error though native takes both, while StyleDescriptor admits var() tuples and null that web cannot serialise: web's vars() threw on undefined and null, and its provider passed raw values into the style. CustomPropertyValue is the set both platforms honour: a string, a number, a boolean, undefined, or an array of those, which is a comma-separated list. Both platforms type vars() and VariableContextProvider with it, and web serialises through one function that leaves an undefined value unset. A type-level fixture holds the three entries to one type, and the README shows the provider by its current name. --- README.md | 14 +++-- src/__tests__/_custom-property-value.types.ts | 60 +++++++++++++++++++ src/__tests__/native/vars.test.tsx | 37 ++++++++++++ src/__tests__/web/variables.test.tsx | 56 +++++++++++++++++ src/native-internal/variables.tsx | 7 ++- src/native/api.tsx | 4 +- src/runtime.types.ts | 10 ++++ src/web/api.tsx | 60 +++++++++++++------ 8 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/_custom-property-value.types.ts create mode 100644 src/__tests__/web/variables.test.tsx diff --git a/README.md b/README.md index bafb2302..36c21a21 100644 --- a/README.md +++ b/README.md @@ -214,26 +214,28 @@ It is preferable that all CSS variables are set via CSS. If you need values to c } ``` -As a last resort, you can use `VariableContext` to dynamically set CSS variables in JavaScript +As a last resort, you can use `VariableContextProvider` to dynamically set CSS variables in JavaScript ```ts -import { VariableContext } from 'react-native-css'; +import { VariableContextProvider } from 'react-native-css'; export default function App() { return ( - + Hello, world! - + ) } ``` -This API only allows for setting CSS variables as primitive values. For more complex styles, you will need to use a helper CSS class. +A custom property holds a token stream on web and a structured value on native, so this API accepts the values both platforms honour: a string, a number, a boolean, or an array of those (the `CustomPropertyValue` type). + +An array is a comma-separated CSS list — a `font-family` stack, a `transition-property` list. A value whose parts are separated by spaces, such as a `box-shadow`, is a single string. `undefined` leaves the property unset, so an ancestor's value inherits. Anything beyond that needs a helper CSS class. > [!IMPORTANT] -> By using `VariableContext` you may need to disable the `inlineVariable` optimization +> By using `VariableContextProvider` you may need to disable the `inlineVariable` optimization ## Optimizations diff --git a/src/__tests__/_custom-property-value.types.ts b/src/__tests__/_custom-property-value.types.ts new file mode 100644 index 00000000..2c75a5fa --- /dev/null +++ b/src/__tests__/_custom-property-value.types.ts @@ -0,0 +1,60 @@ +// Type-level assertions, run by yarn typecheck; the leading underscore keeps jest off this file +import type { CustomPropertyValue } from "react-native-css"; + +type RootApi = typeof import("react-native-css"); +type NativeApi = typeof import("react-native-css/native"); +type WebApi = typeof import("react-native-css/web"); + +type Equal = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false; +type Expect = T; +type Accepts = [TValue] extends [TTarget] ? true : false; + +type VarsParameter unknown }> = + Parameters[0]; +type ProviderValue< + TApi extends { VariableContextProvider: (props: never) => unknown }, +> = Parameters[0]["value"]; + +// The shared entry's declarations reach both platforms, so all three entries must agree +export type Parity = [ + Expect, Record>>, + Expect, VarsParameter>>, + Expect, VarsParameter>>, + Expect< + Equal, Record<`--${string}`, CustomPropertyValue>> + >, + Expect, ProviderValue>>, + Expect, ProviderValue>>, +]; + +export type Accepted = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +export type Rejected = [ + // `null` is not a CSS value — `undefined` is how a property is left unset. + Expect, false>>, + // An object has no custom-property serialisation. + Expect, false>>, + Expect< + Equal< + Accepts<[Record, "var", ["other"]], CustomPropertyValue>, + false + > + >, + Expect< + Equal< + Accepts< + import("react-native-css/compiler").StyleDescriptor, + CustomPropertyValue + >, + false + > + >, +]; diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..b893a77c 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,40 @@ test("vars", () => { color: "blue", }); }); + +test("vars: an array is a list the declaration consumes", () => { + registerCSS(`.my-class { font-variant-caps: var(--font-variant); }`); + + render( + , + ); + + // fontVariant takes an array, so the list must survive the variable pipeline as a list + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontVariant: ["small-caps"], + }); +}); + +test("vars: undefined leaves the property unset", () => { + registerCSS(` + .my-class { color: var(--color); } + .green { --color: green; } + `); + + render( + , + ); + + // An unset inline value lets the class's --color through + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#008000", + }); +}); diff --git a/src/__tests__/web/variables.test.tsx b/src/__tests__/web/variables.test.tsx new file mode 100644 index 00000000..be4e2876 --- /dev/null +++ b/src/__tests__/web/variables.test.tsx @@ -0,0 +1,56 @@ +import { View } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; + +import { VariableContextProvider } from "../../web/api"; + +const testID = "react-native-css"; + +/** The custom properties the provider wrote onto its host element's style. */ +const customProperties = ( + value: Parameters[0]["value"], +): unknown => { + render( + + + , + ); + + return screen.root.props.style; +}; + +test("an array is a comma-separated CSS list", () => { + expect(customProperties({ "--font-stack": ["Inter", "Helvetica"] })).toEqual({ + "display": "contents", + "--font-stack": "Inter,Helvetica", + }); +}); + +test("a nested array flattens into the same list", () => { + expect(customProperties({ "--list": [1, ["a", true]] })).toEqual({ + "display": "contents", + "--list": "1,a,true", + }); +}); + +test("undefined leaves the property unset", () => { + expect(customProperties({ "--set": "red", "--unset": undefined })).toEqual({ + "display": "contents", + "--set": "red", + }); +}); + +test("an undefined member drops out of the list", () => { + expect(customProperties({ "--list": ["a", undefined, "b"] })).toEqual({ + "display": "contents", + "--list": "a,b", + }); +}); + +test("scalars serialise to their token form", () => { + expect(customProperties({ "--number": 1, "--boolean": true })).toEqual({ + "display": "contents", + "--number": "1", + "--boolean": "true", + }); +}); diff --git a/src/native-internal/variables.tsx b/src/native-internal/variables.tsx index 8a0f81e0..a098135a 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -5,9 +5,8 @@ import { type PropsWithChildren, } from "react"; -import type { StyleDescriptor } from "react-native-css/compiler"; - import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import type { CustomPropertyValue } from "../runtime.types"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -17,7 +16,9 @@ globalThis.__react_native_css_variable_context ??= export const VariableContext = globalThis.__react_native_css_variable_context; export function VariableContextProvider( - props: PropsWithChildren<{ value: Record<`--${string}`, StyleDescriptor> }>, + props: PropsWithChildren<{ + value: Record<`--${string}`, CustomPropertyValue>; + }>, ) { const inheritedVariables = useContext(VariableContext); diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..481ba3d8 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -2,11 +2,11 @@ import { useContext, useEffect, useState, type ComponentType } from "react"; import { Appearance, type ViewStyle } from "react-native"; -import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; import type { ColorScheme, + CustomPropertyValue, Props, ReactComponent, Styled, @@ -120,7 +120,7 @@ export function useNativeVariable(name: string) { /** * @deprecated Use `` instead. */ -export function vars(variables: Record): ViewStyle { +export function vars(variables: Record): ViewStyle { return Object.assign( { [VAR_SYMBOL]: "inline" }, Object.fromEntries( diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..093ef8c9 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -138,6 +138,16 @@ export type InlineStyle = | (Record | undefined | null)[] | (() => unknown); +/******************************* Variables ******************************/ + +// The values both platforms honour: web serialises them to a token stream, an array as a comma-separated list +export type CustomPropertyValue = + | string + | number + | boolean + | undefined + | CustomPropertyValue[]; + /********************************* Misc *********************************/ export type Props = Record | undefined | null; diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..81271877 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -13,7 +13,11 @@ import type { StyledOptions, } from "react-native-css"; -import type { ReactComponent, Styled } from "../runtime.types"; +import type { + CustomPropertyValue, + ReactComponent, + Styled, +} from "../runtime.types"; import { assignStyle } from "./assign-style"; const defaultMapping: StyledConfiguration> = { @@ -72,34 +76,54 @@ export const colorScheme: ColorScheme = { }, }; -/** - * @deprecated Use `` instead. - */ -export function vars(variables: Record) { - const $variables: Record = {}; +// undefined leaves the property unset, and drops out of a list +function serializeCustomProperty( + value: CustomPropertyValue, +): string | undefined { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value)) { + return value + .map((member) => serializeCustomProperty(member)) + .filter((member) => member !== undefined) + .join(","); + } + + return String(value); +} + +function toCustomProperties(variables: Record) { + const properties: Record = {}; for (const [key, value] of Object.entries(variables)) { - if (key.startsWith("--")) { - $variables[key] = value.toString(); - } else { - $variables[`--${key}`] = value.toString(); + const serialized = serializeCustomProperty(value); + + if (serialized !== undefined) { + properties[key.startsWith("--") ? key : `--${key}`] = serialized; } } - return $variables; + + return properties; +} + +/** + * @deprecated Use `` instead. + */ +export function vars(variables: Record) { + return toCustomProperties(variables); } export function VariableContextProvider( - props: PropsWithChildren<{ value: Record<`--${string}`, string | number> }>, + props: PropsWithChildren<{ + value: Record<`--${string}`, CustomPropertyValue>; + }>, ) { const style = useMemo(() => { return { display: "contents", - ...Object.fromEntries( - Object.entries(props.value).map(([key, value]) => [ - key.startsWith("--") ? key : `--${key}`, - value, - ]), - ), + ...toCustomProperties(props.value), }; }, [props.value]); From 37932a1a3d0ba5985a4d28bb4c227787c516cdc7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 20:13:41 +0300 Subject: [PATCH 3/3] test(native): read an undefined vars() entry on the runtime path The case compiled with the default options, so the compiler inlined the class's --color into the rule reading it and the result never depended on how the runtime treats an undefined entry. It now keeps the variable, so the class's value reaches the element only because an undefined entry plants no key, and writes the colour as a hex with no name, so both paths serialise it alike. --- src/__tests__/native/vars.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index fc27c098..3eb3b0c9 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -60,10 +60,14 @@ test("vars: an array is a list the declaration consumes", () => { }); test("vars: undefined leaves the property unset", () => { - registerCSS(` + // Kept, so the runtime resolves the variable the inline entry leaves unset + registerCSS( + ` .my-class { color: var(--color); } - .green { --color: green; } - `); + .green { --color: #123456; } + `, + { inlineVariables: false }, + ); render( { // An unset inline value lets the class's --color through expect(screen.getByTestId(testID).props.style).toStrictEqual({ - color: "#008000", + color: "#123456", }); });