From 151b46c997501ea2e636b459a2f777a814af9396 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:09:54 +0300 Subject: [PATCH] fix: type CSS custom properties against what both platforms honour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `react-native-css` resolves to the web declarations on every platform. `.` carries no `react-native` export condition and nothing in the Expo or React Native toolchain sets `moduleSuffixes`, so TypeScript follows `index.d.ts` to `runtime.d.ts` and never to `runtime.native.d.ts` — measured under `bundler`, `bundler` + `customConditions: ["react-native"]` and `nodenext` alike. That made the web signature the only one a consumer sees, and it was narrower than the runtime that actually executes on native: `vars({ "--font-stack": ["Inter", "Helvetica"] })` is a type error on a call the native runtime resolves end to end, and the only way through is a cast. It was also wider on the native declaration than web can serialise: `StyleDescriptor` admits `undefined`, which threw `Cannot read properties of undefined (reading 'toString')` on web. Both planes are now written against one `CustomPropertyValue` — the set of values both implementations honour. An array is a comma-separated CSS list, which is what the web serialisation already produced for a flat array and what the native resolver keeps structured until the declaration reading `var()` consumes it. `undefined` leaves the property unset on both, so an ancestor's value inherits, rather than throwing. This narrows the native declarations by the `StyleFunction` tuples the compiler emits for `var()` and `rgba()`. Those are an internal encoding with no web serialisation, and passing one produced `"[object Object],var,y"` there, so they do not belong in a public type that ships to both platforms. The README's variable section names the component and prop that exist, and states the value contract the new type carries. --- README.md | 14 +-- src/__tests__/_custom-property-value.types.ts | 85 +++++++++++++++++++ src/__tests__/native/vars.test.tsx | 39 +++++++++ src/__tests__/web/variables.test.tsx | 56 ++++++++++++ src/native-internal/variables.tsx | 7 +- src/native/api.tsx | 4 +- src/runtime.types.ts | 27 ++++++ src/web/api.tsx | 56 ++++++++---- 8 files changed, 261 insertions(+), 27 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..37077845 --- /dev/null +++ b/src/__tests__/_custom-property-value.types.ts @@ -0,0 +1,85 @@ +/** + * Compile-time assertions for the value a CSS custom property can be given from + * JavaScript. `yarn typecheck` is the runner — there is nothing here to execute, + * which is why the filename is `_`-prefixed and jest skips it. + * + * The planes are reached as module types rather than imported, so the file stays + * type-only under `verbatimModuleSyntax`. + */ +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 parity invariant. `react-native-css` ships one set of declarations to both + * platforms — there is no `moduleSuffixes` or export condition that hands a + * consumer the native ones — so a value the shared entry accepts has to be a + * value both implementations honour. A future divergence fails here. + */ +export type Parity = [ + Expect, Record>>, + Expect, VarsParameter>>, + Expect, VarsParameter>>, + Expect< + Equal, Record<`--${string}`, CustomPropertyValue>> + >, + Expect, ProviderValue>>, + Expect, ProviderValue>>, +]; + +/** + * An array is a comma-separated CSS list, alongside the scalars a custom + * property can hold. `undefined` leaves the property unset so an ancestor's + * value inherits. + */ +export type Accepted = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** + * A `StyleDescriptor` is wider than a custom property's public value type. Each + * row fails if the type is widened past what both implementations serialise. + */ +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>>, + // A StyleFunction is the compiler's own encoding of `var()`, `rgba()` and the + // rest. The web implementation has no way to serialise one. + Expect< + Equal< + Accepts<[Record, "var", ["other"]], CustomPropertyValue>, + false + > + >, + // The compiler's whole descriptor union — what the native runtime resolves + // internally — is wider than what a caller may hand in. + 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..ddcadba8 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,42 @@ 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` is one of the React Native style properties that takes an + // array, so the list has to 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( + , + ); + + // The class still sets `--color`, so leaving the inline value unset lets it + // through rather than blanking the property. + 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 3d68a3aa..3b738417 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -2,11 +2,11 @@ import { useContext, useState, type ComponentType } from "react"; import { Appearance } 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, StyledConfiguration, @@ -114,7 +114,7 @@ export function useNativeVariable(name: string) { /** * @deprecated Use `` instead. */ -export function vars(variables: Record) { +export function vars(variables: Record) { return Object.assign( { [VAR_SYMBOL]: "inline" }, Object.fromEntries( diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 6e8f9ad8..f5f60bf5 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -136,6 +136,33 @@ export type InlineStyle = | (Record | undefined | null)[] | (() => unknown); +/******************************* Variables ******************************/ + +/** + * A value a CSS custom property can be given from JavaScript, via `vars()` or + * ``. + * + * Both platforms are written against this one type. A custom property holds a + * token stream on web and a structured value on native, so the type is the set + * of values both can honour: + * + * - An array is a comma-separated CSS list — a `font-family` stack, a + * `transition-property` list. A value whose parts are separated by spaces (a + * `box-shadow`, a `transform`) is a single string. + * - `undefined` leaves the property unset, so an ancestor's value inherits. + * + * A `StyleDescriptor` is wider than this: it also covers the `StyleFunction` + * tuples the compiler emits for `var()`, `rgba()` and friends. Those are an + * internal encoding of the native runtime and have no web serialisation, so + * they are not part of the public API. + */ +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 e43f800a..0c6fe476 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -15,7 +15,7 @@ import type { StyledProps, } from "react-native-css"; -import type { ReactComponent } from "../runtime.types"; +import type { CustomPropertyValue, ReactComponent } from "../runtime.types"; import { assignStyle } from "./assign-style"; const defaultMapping: StyledConfiguration> = { @@ -78,33 +78,57 @@ export const colorScheme: ColorScheme = { }; /** - * @deprecated Use `` instead. + * Serialises a custom property into the token stream CSS stores it as. An array + * is a comma-separated list; an `undefined` member drops out of that list, and + * an `undefined` value leaves the property unset altogether. */ -export function vars(variables: Record) { - const $variables: Record = {}; +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]);