Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion src/__tests__/native/variables.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }`);
Expand Down Expand Up @@ -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(
<VariableContextProvider value={{ "--my-var": "red" }}>
<VariableContextProvider value={{ "--my-var": undefined }}>
<View testID={testID} className="test" />
</VariableContextProvider>
</VariableContextProvider>,
);

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(
<VariableContextProvider value={{ "--my-var": undefined }}>
<View testID={testID} className="test" />
</VariableContextProvider>,
);

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(
<VariableContextProvider value={{ "--my-var": undefined }}>
<View testID={testID} className="test" />
</VariableContextProvider>,
);

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(
<VariableContextProvider value={{ "--my-var": "red" }}>
<VariableContextProvider value={{ "--my-var": "unset" }}>
<View testID={testID} className="test" />
</VariableContextProvider>
</VariableContextProvider>,
);

const component = screen.getByTestId(testID);
expect(component.props.style).toStrictEqual({ color: undefined });
});

test("variable overriding with classes", () => {
registerCSS(`
:root {
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/native/vars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
10 changes: 6 additions & 4 deletions src/native-internal/variables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariableContextValue>({
Expand All @@ -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],
Expand Down
8 changes: 2 additions & 6 deletions src/native/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -115,10 +116,5 @@ export function useNativeVariable(name: string) {
* @deprecated Use `<VariableContextProvider />` instead.
*/
export function vars(variables: Record<string, StyleDescriptor>) {
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));
}
25 changes: 25 additions & 0 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,31 @@ export type VariableContextValue = Record<string, StyleDescriptor> & {
[VAR_SYMBOL]: true;
};

/**
* Normalises the `Record<"--name", value>` shape the two JavaScript channels
* into the variable system accept - `vars()` and `<VariableContextProvider />` -
* 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<string, StyleDescriptor>,
): Record<string, StyleDescriptor> {
const record: Record<string, StyleDescriptor> = {};

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