diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx
index 4ec527a9..55e58624 100644
--- a/src/__tests__/native/components.test.tsx
+++ b/src/__tests__/native/components.test.tsx
@@ -1,19 +1,26 @@
+import { memo } from "react";
import {
+ Animated,
+ PlatformColor,
Button as RNButton,
+ Image as RNImage,
KeyboardAvoidingView as RNKeyboardAvoidingView,
+ Text as RNText,
TextInput as RNTextInput,
StyleSheet,
View,
type ButtonProps,
type TextInputProps,
+ type ViewProps,
} from "react-native";
-import { render } from "@testing-library/react-native";
+import { render, screen } from "@testing-library/react-native";
+import { Button } from "react-native-css/components/Button";
import { copyComponentProperties } from "react-native-css/components/copyComponentProperties";
import { KeyboardAvoidingView } from "react-native-css/components/KeyboardAvoidingView";
import { TextInput } from "react-native-css/components/TextInput";
import { registerCSS, testID } from "react-native-css/jest";
-import { useCssElement } from "react-native-css/native";
+import { styled, useCssElement, useNativeCss } from "react-native-css/native";
import type {
StyledConfiguration,
StyledProps,
@@ -151,3 +158,920 @@ test("KeyboardAvoidingView maps content classes through replacement, removal, an
expect(contentStyle).toEqual({ opacity: 0.5 });
}
});
+
+interface LabelledProps extends ViewProps {
+ readonly labelColor?: string;
+ readonly badgeColor?: string;
+}
+
+// nativeStyleMapping is typed against the target's props, so a target:false config needs a component that has some
+const Labelled = (props: LabelledProps) => ;
+
+test("a target:false config does not take a style-target config's declarations", () => {
+ registerCSS(`.c1 { color: orange; } .c2 { color: blue; }`);
+
+ const Styled = styled(Labelled, {
+ className: { target: "style" },
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ });
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({ color: "#ffa500" });
+ expect(component.props.labelColor).toBe("#00f");
+});
+
+test("a target:false config emits no style prop", () => {
+ registerCSS(`.c3 { color: orange; font-size: 20px; }`);
+
+ const Styled = styled(Labelled, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ });
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.labelColor).toBe("#ffa500");
+ expect(component.props).not.toHaveProperty("style");
+});
+
+test("two target:false configs each keep their own declarations", () => {
+ registerCSS(`.c4 { color: orange; } .c5 { color: blue; }`);
+
+ const Styled = styled(Labelled, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ badgeClassName: {
+ target: false,
+ nativeStyleMapping: { color: "badgeColor" },
+ },
+ });
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.labelColor).toBe("#ffa500");
+ expect(component.props.badgeColor).toBe("#00f");
+ expect(component.props).not.toHaveProperty("labelClassName");
+ expect(component.props).not.toHaveProperty("badgeClassName");
+});
+
+test("declaration order does not change what either config gets", () => {
+ registerCSS(
+ `.c6 { color: orange; font-size: 20px; } .c7 { background-color: blue; }`,
+ );
+
+ const Styled = styled(Labelled, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ className: { target: "style" },
+ });
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.labelColor).toBe("#ffa500");
+ expect(component.props.style).toStrictEqual({ backgroundColor: "#00f" });
+ expect(component.props).not.toHaveProperty("labelClassName");
+});
+
+test("an !important declaration keeps to its own config", () => {
+ registerCSS(`
+ .i1 { color: orange !important; }
+ .i2 { color: blue !important; font-size: 11px !important; }
+ `);
+
+ const Styled = styled(Labelled, {
+ className: { target: "style" },
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ });
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({ color: "#ffa500" });
+ expect(component.props.labelColor).toBe("#00f");
+});
+
+test("a declaration no mapping entry names is dropped, not moved into style", () => {
+ registerCSS(`.d1 { transform: translateX(10px); color: orange; }`);
+
+ const Mapped = styled(Labelled, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ });
+ const Shorthand = styled(Labelled, { labelClassName: false });
+
+ const mapped = render(
+ ,
+ ).getByTestId(testID);
+ expect(mapped.props.labelColor).toBe("#ffa500");
+ expect(mapped.props).not.toHaveProperty("style");
+
+ const shorthand = render(
+ ,
+ ).getByTestId("shorthand");
+ expect(shorthand.props).not.toHaveProperty("style");
+});
+
+test("a target:false mapping on an image with object-fit holds on every render", () => {
+ registerCSS(
+ `.contain { object-fit: contain; width: 10px; } .tint { color: red; }`,
+ );
+ const TintedImage = styled(RNImage, {
+ tintClassName: {
+ target: false,
+ nativeStyleMapping: { color: "tintColor" },
+ },
+ className: "style",
+ });
+ const element = () => (
+
+ );
+
+ render(element());
+ for (let round = 0; round < 3; round++) {
+ const image = screen.getByTestId(testID);
+ expect(image.props.tintColor).toBe("#f00");
+ expect(StyleSheet.flatten(image.props.style)).toEqual({
+ width: 10,
+ objectFit: "contain",
+ });
+ screen.rerender(element());
+ }
+});
+
+test("a target:false mapping after a style config leaves style alone on every render", () => {
+ registerCSS(`.base { font-size: 12px; } .selection { color: red; }`);
+ const SelectableText = styled(RNText, {
+ className: "style",
+ selectionClassName: {
+ target: false,
+ nativeStyleMapping: { color: "selectionColor" },
+ },
+ });
+ const element = () => (
+
+ );
+
+ render(element());
+ for (let round = 0; round < 3; round++) {
+ const text = screen.getByTestId(testID);
+ expect(text.props.selectionColor).toBe("#f00");
+ expect(text.props.style).toStrictEqual({ fontSize: 12 });
+ screen.rerender(element());
+ }
+});
+
+test("a mapping into an earlier config's target keeps its value on every render", () => {
+ registerCSS(`.faded { opacity: 0.5; } .label { color: blue; }`);
+ const Bordered = styled(Labelled, {
+ className: "style",
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.borderColor" },
+ },
+ });
+ const element = () => (
+
+ );
+
+ render(element());
+ for (let round = 0; round < 3; round++) {
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ opacity: 0.5,
+ borderColor: "#00f",
+ });
+ screen.rerender(element());
+ }
+});
+
+test("a target:false mapping into style keeps its value where no inline style meets it", () => {
+ registerCSS(`.label { color: blue; }`);
+ const Bordered = styled(Labelled, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.borderColor" },
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ borderColor: "#00f",
+ });
+});
+
+const animatedOpacity = new Animated.Value(0.5);
+const inlineStyleFunction = () => ({ margin: 1 });
+
+const inlineStyleArray = [{ margin: 1 }];
+
+test.each([
+ [
+ "an inline style object",
+ "style",
+ "style.borderColor",
+ { margin: 1 },
+ { margin: 1, borderColor: "#00f" },
+ { margin: 1, borderColor: "#00f" },
+ ],
+ [
+ "an inline style holding an Animated value",
+ "style",
+ "style.borderColor",
+ { opacity: animatedOpacity },
+ { opacity: animatedOpacity, borderColor: "#00f" },
+ { opacity: animatedOpacity, borderColor: "#00f" },
+ ],
+ [
+ "an inline ripple",
+ "android_ripple",
+ "android_ripple.color",
+ { borderless: true },
+ { borderless: true, color: "#00f" },
+ { borderless: true, color: "#00f" },
+ ],
+ [
+ "an inline style function",
+ "style",
+ "style.borderColor",
+ inlineStyleFunction,
+ inlineStyleFunction,
+ { borderColor: "#00f" },
+ ],
+ [
+ "an inline style array",
+ "style",
+ "style.borderColor",
+ inlineStyleArray,
+ inlineStyleArray,
+ { borderColor: "#00f" },
+ ],
+ [
+ "an inline style of false",
+ "style",
+ "style.borderColor",
+ false,
+ false,
+ { borderColor: "#00f" },
+ ],
+ [
+ "an inline style of null",
+ "style",
+ "style.borderColor",
+ null,
+ null,
+ { borderColor: "#00f" },
+ ],
+] as const)(
+ "%s beside a target:false mapping into it settles key by key where it is an object, and whole where it is not",
+ (_, prop, destination, inlineValue, withNormal, withImportant) => {
+ for (const [css, expected] of [
+ [`.label { color: blue; }`, withNormal],
+ [`.label { color: blue !important; }`, withImportant],
+ ] as const) {
+ registerCSS(css);
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(Received, {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: destination },
+ },
+ });
+
+ const { unmount } = render(
+ ,
+ );
+
+ expect({ css, value: received[prop] }).toStrictEqual({
+ css,
+ value: expected,
+ });
+ unmount();
+ }
+ },
+);
+
+test("the built-in Button takes an !important class's color over its color prop", () => {
+ registerCSS(`.important { color: red !important; } .plain { color: red; }`);
+
+ render();
+ expect(screen.UNSAFE_getByType(RNButton).props.color).toBe("#f00");
+
+ screen.rerender();
+ expect(screen.UNSAFE_getByType(RNButton).props.color).toBe("blue");
+});
+
+const labelMapping = {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+} as const;
+
+test.each([
+ ["alone", { labelClassName: labelMapping }],
+ [
+ "after a style config",
+ { className: "style", labelClassName: labelMapping },
+ ],
+ [
+ "before a style config",
+ { labelClassName: labelMapping, className: "style" },
+ ],
+] as const)(
+ "a target:false mapping's !important value beats an inline value of its prop, %s",
+ (_, mapping) => {
+ registerCSS(`.box { opacity: 0.5; } .label { color: blue !important; }`);
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(Received, mapping);
+
+ render(
+ ,
+ );
+
+ expect(received.labelColor).toBe("#00f");
+ },
+);
+
+test.each([
+ ["after", { className: "style", labelClassName: false }],
+ ["before", { labelClassName: false, className: "style" }],
+] as const)(
+ "a style config's own !important prop beats its inline value with a bare false config %s it",
+ (_, mapping) => {
+ registerCSS(
+ `.caret { caret-color: red !important; } .label { color: blue; }`,
+ );
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(Received, mapping);
+
+ render(
+ ,
+ );
+
+ expect(received.cursorColor).toBe("#f00");
+ },
+);
+
+test.each(["first", "last"] as const)(
+ "a ripple's normal and !important declarations meet key by key with a style config %s",
+ (order) => {
+ registerCSS(
+ `.box { opacity: 0.5; } .ripple { color: blue; border-radius: 4px !important; }`,
+ );
+ const rippleClassName = {
+ target: false,
+ nativeStyleMapping: {
+ color: "android_ripple.color",
+ borderRadius: "android_ripple.radius",
+ },
+ } as const;
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(
+ Received,
+ order === "first"
+ ? { className: "style", rippleClassName }
+ : { rippleClassName, className: "style" },
+ );
+
+ render();
+
+ expect(received.android_ripple).toStrictEqual({ color: "#00f", radius: 4 });
+ },
+);
+
+test.each([
+ [
+ "one config's normal and !important declarations",
+ `.mapped { color: blue; background-color: red !important; }`,
+ {
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: {
+ color: "style.borderColor",
+ backgroundColor: "style.backgroundColor",
+ },
+ },
+ },
+ { labelClassName: "mapped" },
+ { style: { borderColor: "#00f", backgroundColor: "#f00" } },
+ ],
+ [
+ "two configs, one of them !important",
+ `.border { color: blue; } .fill { color: red !important; }`,
+ {
+ borderClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.borderColor" },
+ },
+ fillClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.backgroundColor" },
+ },
+ },
+ { borderClassName: "border", fillClassName: "fill" },
+ { style: { borderColor: "#00f", backgroundColor: "#f00" } },
+ ],
+ [
+ "a ripple's normal and !important declarations",
+ `.ripple { color: blue; border-radius: 4px !important; }`,
+ {
+ rippleClassName: {
+ target: false,
+ nativeStyleMapping: {
+ color: "android_ripple.color",
+ borderRadius: "android_ripple.radius",
+ },
+ },
+ },
+ { rippleClassName: "ripple" },
+ { android_ripple: { color: "#00f", radius: 4 } },
+ ],
+] as const)(
+ "%s mapped into one prop by target:false configs meet key by key",
+ (_, css, mapping, classNames, expected) => {
+ registerCSS(css);
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(Received, mapping);
+
+ render();
+
+ expect(received).toStrictEqual(expected);
+ },
+);
+
+test("a mapping reads the props as computed, not what another mapping wrote, in either order", () => {
+ registerCSS(`.base { border-color: green; } .label { color: blue; }`);
+ const labelClassName = {
+ target: false,
+ nativeStyleMapping: { color: "style.borderColor" },
+ } as const;
+ const className = {
+ target: "style",
+ nativeStyleMapping: { borderColor: "badgeColor" },
+ } as const;
+
+ const rendered = [
+ { labelClassName, className },
+ { className, labelClassName },
+ ].map((mapping) => {
+ let received: Readonly> = {};
+ const Received = (props: Readonly>) => {
+ received = props;
+ return null;
+ };
+ const Mapped = styled(Received, mapping);
+ const { unmount } = render(
+ ,
+ );
+ unmount();
+ return received;
+ });
+
+ expect(rendered).toStrictEqual([
+ { style: { borderColor: "#00f" }, badgeColor: "#008000" },
+ { style: { borderColor: "#00f" }, badgeColor: "#008000" },
+ ]);
+});
+
+interface HeaderOptions {
+ readonly headerStyle?: Readonly>;
+ readonly headerTintColor?: string;
+ readonly title?: string;
+}
+
+interface HeaderProps {
+ readonly options?: HeaderOptions;
+}
+
+test.each([
+ ["normal", "first", "no inline options", "", undefined],
+ ["normal", "last", "no inline options", "", undefined],
+ ["!important", "first", "no inline options", " !important", undefined],
+ ["!important", "last", "no inline options", " !important", undefined],
+ ["normal", "first", "inline options", "", { title: "Home" }],
+ ["normal", "last", "inline options", "", { title: "Home" }],
+ ["!important", "first", "inline options", " !important", { title: "Home" }],
+ ["!important", "last", "inline options", " !important", { title: "Home" }],
+] as const)(
+ "a %s mapping into a sibling of a two-key target keeps its value with the target:false config %s and %s",
+ (_, position, __, importance, inlineOptions) => {
+ registerCSS(
+ `.header { background-color: blue; } .tint { color: red${importance}; }`,
+ );
+ const seen: (HeaderOptions | undefined)[] = [];
+ const Header = (props: HeaderProps) => {
+ seen.push(props.options);
+ return null;
+ };
+ const header = { headerClassName: "options.headerStyle" } as const;
+ const tint = {
+ tintClassName: {
+ target: false,
+ nativeStyleMapping: { color: "options.headerTintColor" },
+ },
+ } as const;
+ const Mapped = styled(
+ Header,
+ position === "first" ? { ...tint, ...header } : { ...header, ...tint },
+ );
+ const element = () => (
+
+ );
+
+ render(element());
+ screen.rerender(element());
+ screen.rerender(element());
+
+ expect(seen.at(-1)).toStrictEqual({
+ ...inlineOptions,
+ headerStyle: { backgroundColor: "#00f" },
+ headerTintColor: "#f00",
+ });
+ expect(new Set(seen.map((options) => JSON.stringify(options))).size).toBe(
+ 1,
+ );
+ },
+);
+
+const receivedProps = (
+ mapping: Parameters[1],
+ props: Readonly>,
+): Readonly> => {
+ let received: Readonly> = {};
+ const Received = (receivedByComponent: Readonly>) => {
+ received = receivedByComponent;
+ return null;
+ };
+ const Mapped = styled(Received, mapping);
+ const { unmount } = render();
+ unmount();
+ return received;
+};
+
+test("an !important platform color beats a string beside it, inline or normal", () => {
+ registerCSS(`
+ .normal-caret { caret-color: red; color: red; }
+ .important-caret { --platform: platformColor(systemBlue); caret-color: var(--platform) !important; color: var(--platform) !important; }
+ `);
+ const platformBlue = PlatformColor("systemBlue");
+
+ expect(
+ receivedProps(
+ { className: "style" },
+ { className: "important-caret", cursorColor: "green" },
+ ).cursorColor,
+ ).toStrictEqual(platformBlue);
+ expect(
+ receivedProps(
+ { className: "style" },
+ { className: "normal-caret important-caret" },
+ ).cursorColor,
+ ).toStrictEqual(platformBlue);
+ expect(
+ receivedProps(
+ { className: { target: false, nativeStyleMapping: { color: "color" } } },
+ { className: "normal-caret important-caret", color: "green" },
+ ).color,
+ ).toStrictEqual(platformBlue);
+
+ render();
+ expect(screen.UNSAFE_getByType(RNButton).props.color).toStrictEqual(
+ platformBlue,
+ );
+});
+
+test.each([
+ ["no header class", {}],
+ ["a header class that matches nothing", { headerClassName: "unmatched" }],
+ ["inline options of null", { options: null }],
+] as const)(
+ "an !important mapping beside a two-key target keeps its value with %s and the target:false config last",
+ (_, props) => {
+ registerCSS(`.tint { color: red !important; }`);
+
+ expect(
+ receivedProps(
+ {
+ headerClassName: "options.headerStyle",
+ tintClassName: {
+ target: false,
+ nativeStyleMapping: { color: "options.headerTintColor" },
+ },
+ },
+ { tintClassName: "tint", ...props },
+ ).options,
+ ).toStrictEqual({ headerTintColor: "#f00" });
+ },
+);
+
+test("settling a mapping beside a target writes nothing into the inline object it meets", () => {
+ registerCSS(`.tint { color: red !important; }`);
+ const options = Object.freeze({ title: "Home" });
+
+ expect(
+ receivedProps(
+ {
+ headerClassName: "options.headerStyle",
+ tintClassName: {
+ target: false,
+ nativeStyleMapping: { color: "options.headerTintColor" },
+ },
+ },
+ { headerClassName: "unmatched", tintClassName: "tint", options },
+ ).options,
+ ).toStrictEqual({ title: "Home", headerTintColor: "#f00" });
+ expect(options).toStrictEqual({ title: "Home" });
+});
+
+test.each([null, false] as const)(
+ "an inline ripple of %p clears a class ripple, as it does without one",
+ (inlineRipple) => {
+ registerCSS(`.ripple { -rn-ripple-color: red; }`);
+
+ expect(
+ receivedProps(
+ { className: "style" },
+ { className: "ripple", android_ripple: inlineRipple },
+ ).android_ripple,
+ ).toBe(inlineRipple);
+ },
+);
+
+test("an !important mapping into a hitSlop side replaces an inline hitSlop number", () => {
+ registerCSS(`.slop { width: 10px !important; }`);
+
+ expect(
+ receivedProps(
+ {
+ slopClassName: {
+ target: false,
+ nativeStyleMapping: { width: "hitSlop.top" },
+ },
+ },
+ { slopClassName: "slop", hitSlop: 5 },
+ ).hitSlop,
+ ).toStrictEqual({ top: 10 });
+});
+
+test("an !important mapping of a whole prop replaces a normal mapping into one of its keys", () => {
+ registerCSS(`.side { width: 10px; } .whole { height: 20px !important; }`);
+
+ expect(
+ receivedProps(
+ {
+ sideClassName: {
+ target: false,
+ nativeStyleMapping: { width: "hitSlop.top" },
+ },
+ wholeClassName: {
+ target: false,
+ nativeStyleMapping: { height: "hitSlop" },
+ },
+ },
+ { sideClassName: "side", wholeClassName: "whole" },
+ ).hitSlop,
+ ).toBe(20);
+});
+
+test("a mapping into an entry of a transform keeps the transform an array", () => {
+ registerCSS(
+ `.turned { transform: rotate(45deg) translateX(10px); } .label { color: red; }`,
+ );
+
+ expect(
+ receivedProps(
+ {
+ className: "style",
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.transform.1.label" },
+ },
+ },
+ { className: "turned", labelClassName: "label" },
+ ).style,
+ ).toStrictEqual({
+ transform: [{ rotate: "45deg" }, { translateX: 10, label: "#f00" }],
+ });
+});
+
+test("an empty inline object leaves a mapped array as it is", () => {
+ registerCSS(`.turned { transform: rotate(45deg); }`);
+
+ expect(
+ receivedProps(
+ {
+ turnClassName: {
+ target: false,
+ nativeStyleMapping: { transform: "turns" },
+ },
+ },
+ { turnClassName: "turned", turns: {} },
+ ).turns,
+ ).toStrictEqual([{ rotate: "45deg" }]);
+});
+
+test("a memoized child renders once under an !important ripple while the classes stay the same", () => {
+ registerCSS(
+ `.box { opacity: 0.5; } .ripple { color: blue; border-radius: 4px !important; }`,
+ );
+ let renders = 0;
+ const CountedView = memo(
+ (props: ViewProps & { android_ripple?: unknown }) => {
+ renders++;
+ return ;
+ },
+ );
+ const Styled = styled(CountedView, {
+ className: "style",
+ rippleClassName: {
+ target: false,
+ nativeStyleMapping: {
+ color: "android_ripple.color",
+ borderRadius: "android_ripple.radius",
+ },
+ },
+ });
+ const element = () => (
+
+ );
+
+ render(element());
+ screen.rerender(element());
+ screen.rerender(element());
+
+ expect(renders).toBe(1);
+ expect(screen.getByTestId(testID).props.android_ripple).toStrictEqual({
+ color: "#00f",
+ radius: 4,
+ });
+});
+
+test("a mapping builds its props once while the classes stay the same", () => {
+ registerCSS(`.c10 { color: orange; opacity: 0.5; } .c11 { color: blue; }`);
+ let renders = 0;
+ const CountedLabelled = memo((props: LabelledProps) => {
+ renders++;
+ return ;
+ });
+ const Styled = styled(CountedLabelled, {
+ className: { target: "style", nativeStyleMapping: { color: "labelColor" } },
+ borderClassName: {
+ target: false,
+ nativeStyleMapping: { color: "style.borderColor" },
+ },
+ });
+ const element = () => (
+
+ );
+
+ render(element());
+ screen.rerender(element());
+ screen.rerender(element());
+
+ expect(renders).toBe(1);
+ expect(screen.getByTestId(testID).props).toMatchObject({
+ labelColor: "#ffa500",
+ style: { opacity: 0.5, borderColor: "#00f" },
+ });
+});
+
+test("a target:false config follows its class as it is set, removed and set again", () => {
+ registerCSS(`.c8 { background-color: blue; } .c9 { color: orange; }`);
+ const Styled = styled(Labelled, {
+ className: "style",
+ labelClassName: {
+ target: false,
+ nativeStyleMapping: { color: "labelColor" },
+ },
+ });
+
+ render();
+ for (const [labelClassName, labelColor] of [
+ ["c9", "#ffa500"],
+ [undefined, undefined],
+ ["c9", "#ffa500"],
+ ] as const) {
+ screen.rerender(
+ ,
+ );
+ const component = screen.getByTestId(testID);
+ expect(component.props.labelColor).toBe(labelColor);
+ expect(component.props.style).toStrictEqual({ backgroundColor: "#00f" });
+ }
+});
+
+// docs/v5-engine-contracts.md: target: false discards unmapped compiled styles while retaining original inline styles
+test("a target:false class's declarations that name their own props stay under it on an Image", () => {
+ registerCSS(`.tint { color: red; object-fit: cover; opacity: 0.5; }`);
+ const TintedImage = styled(RNImage, {
+ tintClassName: {
+ target: false,
+ nativeStyleMapping: { color: "tintColor" },
+ },
+ });
+
+ render(
+ ,
+ );
+ const image = screen.getByTestId(testID);
+
+ expect(image.props.tintColor).toBe("#f00");
+ expect(image.props.style).toBeUndefined();
+});
+
+test.each([
+ ["drops them unmapped", { color: "selectionColor" }, {}],
+ [
+ "maps one it names",
+ { color: "selectionColor", cursorColor: true },
+ { cursorColor: "#008000" },
+ ],
+] as const)(
+ "a target:false class's declarations that name their own props stay under it on a TextInput, and the mapping %s",
+ (_, nativeStyleMapping, expected) => {
+ registerCSS(
+ `.label { color: red; caret-color: green; -webkit-line-clamp: 2; }`,
+ );
+ const LabelledInput = styled(RNTextInput, {
+ labelClassName: { target: false, nativeStyleMapping },
+ });
+
+ render();
+ const { selectionColor, cursorColor, numberOfLines } =
+ screen.getByTestId(testID).props;
+
+ expect({ selectionColor, cursorColor, numberOfLines }).toStrictEqual({
+ selectionColor: "#f00",
+ cursorColor: undefined,
+ numberOfLines: undefined,
+ ...expected,
+ });
+ },
+);
+
+test("useNativeCss renders a component it is given no props for", () => {
+ const Bare = () => useNativeCss(View, null);
+
+ render();
+
+ expect(screen.UNSAFE_getByType(View).props.style).toBeUndefined();
+});
diff --git a/src/__tests__/native/image-fit.test.tsx b/src/__tests__/native/image-fit.test.tsx
index de49f163..2b608300 100644
--- a/src/__tests__/native/image-fit.test.tsx
+++ b/src/__tests__/native/image-fit.test.tsx
@@ -85,3 +85,55 @@ test("important image fitting overrides inline style and preserves its siblings"
);
expect(style()).toMatchObject({ objectFit: "cover", opacity: 0.5 });
});
+
+describe("a mapping on an image with object-fit", () => {
+ const TintedImage = styled(RNImage, {
+ tintClassName: {
+ target: "style",
+ nativeStyleMapping: { color: "tintColor" },
+ },
+ className: "style",
+ });
+
+ test.each([
+ ["a normal", `.tint { color: red; }`],
+ ["an important", `.tint { color: red !important; }`],
+ ])(
+ "keeps %s mapped value when another config follows it, on every render",
+ (_, tintCss) => {
+ registerCSS(`.contain { object-fit: contain; } ${tintCss}`);
+ const element = () => (
+
+ );
+
+ render(element());
+ expect(props().tintColor).toBe("#f00");
+ expect(style()?.objectFit).toBe("contain");
+
+ screen.rerender(element());
+ expect(props().tintColor).toBe("#f00");
+ expect(style()?.objectFit).toBe("contain");
+ },
+ );
+
+ test("keeps the same style object while its classes stay the same", () => {
+ registerCSS(`.contain { object-fit: contain; } .tint { color: red; }`);
+ const element = () => (
+
+ );
+
+ render(element());
+ const firstStyle = props().style;
+ screen.rerender(element());
+
+ expect(props().style).toBe(firstStyle);
+ });
+});
diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts
new file mode 100644
index 00000000..853746ca
--- /dev/null
+++ b/src/__tests__/native/objects.test.ts
@@ -0,0 +1,168 @@
+import {
+ isPlainObject,
+ valueAtPath,
+ withDeepPaths,
+ withoutKeys,
+ withoutKeysAt,
+} from "../../native/objects";
+
+describe("withDeepPaths", () => {
+ test("sets a nested value on copies of the objects on its path, leaving the source untouched", () => {
+ const style = { color: "red" };
+ const source = { style, testID: "image" };
+
+ const result = withDeepPaths(source, [[["style", "tintColor"], "blue"]]);
+
+ expect(result).toStrictEqual({
+ style: { color: "red", tintColor: "blue" },
+ testID: "image",
+ });
+ expect(result.style).not.toBe(style);
+ expect(source).toStrictEqual({ style: { color: "red" }, testID: "image" });
+ });
+
+ test("creates the objects its path is missing", () => {
+ expect(withDeepPaths({}, [[["outer", "inner"], 1]])).toStrictEqual({
+ outer: { inner: 1 },
+ });
+ });
+
+ test("keeps the symbol-keyed properties of the objects on its path", () => {
+ const marker = Symbol("marker");
+ const source = { [marker]: true, style: { [marker]: true, color: "red" } };
+
+ expect(withDeepPaths(source, [[["style", "opacity"], 0.5]])).toStrictEqual({
+ [marker]: true,
+ style: { [marker]: true, color: "red", opacity: 0.5 },
+ });
+ });
+
+ test("returns its source when no write has a path", () => {
+ const source = { style: { color: "red" } };
+
+ expect(withDeepPaths(source, [])).toBe(source);
+ expect(withDeepPaths(source, [[[], "blue"]])).toBe(source);
+ });
+
+ test("writes in order, a later write replacing what an earlier one wrote at its path", () => {
+ expect(
+ withDeepPaths({}, [
+ [["style", "color"], "red"],
+ [["style", "opacity"], 0.5],
+ ]),
+ ).toStrictEqual({ style: { color: "red", opacity: 0.5 } });
+ expect(
+ withDeepPaths({}, [
+ [["style", "color"], "red"],
+ [["style"], { opacity: 0.5 }],
+ ]),
+ ).toStrictEqual({ style: { opacity: 0.5 } });
+ });
+
+ test("copies an object a write placed before a later write reaches into it", () => {
+ const written = { opacity: 0.5 };
+
+ const result = withDeepPaths({}, [
+ [["style"], written],
+ [["style", "color"], "red"],
+ ]);
+
+ expect(result).toStrictEqual({ style: { opacity: 0.5, color: "red" } });
+ expect(written).toStrictEqual({ opacity: 0.5 });
+ });
+
+ test("gives the same result for the same input, however often it runs", () => {
+ const source = { style: { color: "red" } };
+
+ const first = withDeepPaths(source, [[["style", "opacity"], 0.5]]);
+ const second = withDeepPaths(source, [[["style", "opacity"], 0.5]]);
+
+ expect(first).toStrictEqual(second);
+ expect(first).not.toBe(second);
+ expect(source).toStrictEqual({ style: { color: "red" } });
+ });
+});
+
+describe("isPlainObject", () => {
+ test.each([
+ ["an object literal", { color: "red" }, true],
+ ["an object with no prototype", Object.create(null) as unknown, true],
+ ["an array", [1], false],
+ ["a function", () => ({}), false],
+ ["a class instance", new Map(), false],
+ ["null", null, false],
+ ["a string", "red", false],
+ ])("%s is plain: %s", (_, value, expected) => {
+ expect(isPlainObject(value)).toBe(expected);
+ });
+});
+
+describe("withoutKeys", () => {
+ test("returns the source itself when it has none of the keys", () => {
+ const source = { color: "red" };
+
+ expect(withoutKeys(source, ["tintColor"])).toBe(source);
+ });
+
+ test("keeps a symbol-keyed property", () => {
+ const marker = Symbol("marker");
+ const source = { [marker]: true, color: "red", opacity: 1 };
+
+ expect(withoutKeys(source, ["color"])).toStrictEqual({
+ [marker]: true,
+ opacity: 1,
+ });
+ });
+
+ test("drops the keys from a copy, leaving the source untouched", () => {
+ const source = { color: "red", opacity: 1 };
+
+ expect(withoutKeys(source, ["color"])).toStrictEqual({ opacity: 1 });
+ expect(source).toStrictEqual({ color: "red", opacity: 1 });
+ });
+});
+
+describe("valueAtPath", () => {
+ test("reads the value at the end of its path, through objects and arrays", () => {
+ const source = { style: { transform: [{ rotate: "45deg" }] } };
+
+ expect(valueAtPath(source, ["style", "transform", "0", "rotate"])).toBe(
+ "45deg",
+ );
+ });
+
+ test("answers undefined for a key the path misses or a value it cannot read into", () => {
+ expect(valueAtPath({ style: {} }, ["style", "color"])).toBeUndefined();
+ expect(valueAtPath({ style: "red" }, ["style", "color"])).toBeUndefined();
+ expect(valueAtPath(null, ["style"])).toBeUndefined();
+ });
+
+ test("returns its source for an empty path", () => {
+ const source = { style: {} };
+
+ expect(valueAtPath(source, [])).toBe(source);
+ });
+});
+
+describe("withoutKeysAt", () => {
+ test("drops the keys from a copy of the object at its path, leaving the source untouched", () => {
+ const style = { color: "red", opacity: 1 };
+ const source = { nested: { style }, testID: "view" };
+
+ const result = withoutKeysAt(source, ["nested", "style"], ["color"]);
+
+ expect(result).toStrictEqual({
+ nested: { style: { opacity: 1 } },
+ testID: "view",
+ });
+ expect(source).toStrictEqual({ nested: { style }, testID: "view" });
+ });
+
+ test("returns its source when its path meets no object or the object has none of the keys", () => {
+ const source = { style: { opacity: 1 }, label: "x" };
+
+ expect(withoutKeysAt(source, ["label", "style"], ["color"])).toBe(source);
+ expect(withoutKeysAt(source, ["missing"], ["color"])).toBe(source);
+ expect(withoutKeysAt(source, ["style"], ["color"])).toBe(source);
+ });
+});
diff --git a/src/__tests__/native/styled.test.ios.tsx b/src/__tests__/native/styled.test.ios.tsx
index 3f4ef620..3f958488 100644
--- a/src/__tests__/native/styled.test.ios.tsx
+++ b/src/__tests__/native/styled.test.ios.tsx
@@ -181,6 +181,39 @@ test.each([false, true])(
},
);
+test("a mapping onto a nested target waits for its class, then maps on every render", () => {
+ registerCSS(`.faded { opacity: 0.5; } .label { color: blue; }`);
+ const Base = (props: {
+ style?: StyleProp;
+ nested?: { style?: StyleProp };
+ accessibilityLabel?: string;
+ }) => (
+
+ );
+ const Labelled = styled(Base, {
+ className: "style",
+ labelClassName: {
+ target: "nested.style",
+ nativeStyleMapping: { color: "accessibilityLabel" },
+ },
+ });
+
+ render();
+ expect(screen.getByTestId(testID).props.accessibilityLabel).toBeUndefined();
+
+ for (let round = 0; round < 2; round++) {
+ screen.rerender();
+ expect(screen.getByTestId(testID).props.accessibilityLabel).toBe("#00f");
+ expect(StyleSheet.flatten(screen.getByTestId(testID).props.style)).toEqual({
+ opacity: 0.5,
+ });
+ }
+});
+
test("passThrough resolves three segment targets and updates without leaking root props", () => {
registerCSS(`.first { width: 40px; } .second { width: 80px; }`);
interface Props {
diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts
index 00e08785..dfa51685 100644
--- a/src/compiler/compiler.types.ts
+++ b/src/compiler/compiler.types.ts
@@ -73,7 +73,7 @@ export interface StyleRule {
dv?: number;
// Target override
- target?: string | string[] | false;
+ target?: string | readonly string[] | CollectedTarget;
/**
* Conditionals
@@ -96,6 +96,11 @@ export interface StyleRule {
a?: boolean;
}
+// A target:false config's rules collect their declarations, and the props those name, under its source key, where its mapping reads them
+export interface CollectedTarget {
+ readonly collect: string;
+}
+
export type StyleDeclaration =
/** This is a static style object */
| Record
diff --git a/src/native/objects.ts b/src/native/objects.ts
index 12e69ccd..dd06ea34 100644
--- a/src/native/objects.ts
+++ b/src/native/objects.ts
@@ -2,7 +2,10 @@
import { ShortHandSymbol } from "../native/styles/constants";
import { transformKeys } from "../native/styles/defaults";
-export function getDeepPath(source: any, paths: string | string[] | false) {
+export function getDeepPath(
+ source: any,
+ paths: string | readonly string[] | false,
+) {
if (!source) {
return;
}
@@ -11,18 +14,8 @@ export function getDeepPath(source: any, paths: string | string[] | false) {
return undefined;
}
- if (Array.isArray(paths)) {
- let target = source;
-
- for (const path of paths) {
- if (typeof target !== "object" || !target || !(path in target)) {
- return undefined;
- }
-
- target = target[path];
- }
-
- return target;
+ if (typeof paths !== "string") {
+ return valueAtPath(source, paths);
} else if (transformKeys.has(paths)) {
return Array.isArray(source?.transform)
? source.transform.find((t: any) => t[paths] !== undefined)
@@ -32,6 +25,20 @@ export function getDeepPath(source: any, paths: string | string[] | false) {
}
}
+export function valueAtPath(source: unknown, path: readonly string[]): unknown {
+ let value = source;
+
+ for (const key of path) {
+ if (typeof value !== "object" || value === null || !(key in value)) {
+ return undefined;
+ }
+
+ value = Reflect.get(value, key);
+ }
+
+ return value;
+}
+
export function applyShorthand(value: any) {
if (value === undefined) {
return;
@@ -107,3 +114,90 @@ export function setDeepPath(
return target;
}
+
+export function isNonArrayObject(
+ value: unknown,
+): value is Readonly> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function isPlainObject(
+ value: unknown,
+): value is Readonly> {
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+ const prototype: unknown = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+// Writes each value at its path in order, copying each object or array on the way once however many writes reach it
+export function withDeepPaths(
+ source: Readonly>,
+ writes: readonly (readonly [readonly string[], unknown])[],
+): Readonly> {
+ const copies = new Set();
+ const copyOf = (value: unknown): object => {
+ const copy: object = Array.isArray(value)
+ ? [...(value as readonly unknown[])]
+ : { ...(isNonArrayObject(value) ? value : {}) };
+ copies.add(copy);
+ return copy;
+ };
+
+ let result: Record | undefined;
+ for (const [path, value] of writes) {
+ const key = path[path.length - 1];
+ if (key === undefined) {
+ continue;
+ }
+
+ if (result === undefined) {
+ result = { ...source };
+ copies.add(result);
+ }
+ let target: object = result;
+ for (const segment of path.slice(0, -1)) {
+ const child: unknown = Reflect.get(target, segment);
+ const next =
+ typeof child === "object" && child !== null && copies.has(child)
+ ? child
+ : copyOf(child);
+ Reflect.set(target, segment, next);
+ target = next;
+ }
+ Reflect.set(target, key, value);
+ }
+
+ return result ?? source;
+}
+
+export function withoutKeys(
+ source: Readonly>,
+ keys: readonly string[],
+): Readonly> {
+ return keys.reduce((result, key) => {
+ if (!(key in result)) return result;
+ const { [key]: removed, ...rest } = result;
+ return rest;
+ }, source);
+}
+
+export function withoutKeysAt(
+ source: Readonly>,
+ paths: readonly string[],
+ keys: readonly string[],
+): Readonly> {
+ const [path, ...rest] = paths;
+ if (path === undefined) {
+ return withoutKeys(source, keys);
+ }
+
+ const child = source[path];
+ if (!isNonArrayObject(child)) {
+ return source;
+ }
+
+ const updated = withoutKeysAt(child, rest, keys);
+ return updated === child ? source : { ...source, [path]: updated };
+}
diff --git a/src/native/react/collection-target.ts b/src/native/react/collection-target.ts
new file mode 100644
index 00000000..ec386859
--- /dev/null
+++ b/src/native/react/collection-target.ts
@@ -0,0 +1,24 @@
+import type { CollectedTarget, StyleRule } from "react-native-css/compiler";
+
+import type { Config } from "./useNativeCss";
+
+type RuleTarget = NonNullable;
+
+// A target:false config collects under its own source prop, which getStyledProps strips once its mapping has run; style belongs to the configs that target it
+export function collectionTarget(config: Config): RuleTarget {
+ return config.target === false ? { collect: config.source } : config.target;
+}
+
+export function targetPath(target: Readonly): readonly string[] {
+ if (typeof target === "string") {
+ return [target];
+ }
+
+ return collectsProps(target) ? [target.collect] : target;
+}
+
+export function collectsProps(
+ target: Readonly,
+): target is Readonly {
+ return typeof target === "object" && "collect" in target;
+}
diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts
index e0fe8d1a..38fb76b8 100644
--- a/src/native/react/rules.ts
+++ b/src/native/react/rules.ts
@@ -18,6 +18,7 @@ import {
type VariableContextValue,
} from "../reactivity";
import { stylesFamily } from "../styles";
+import { collectionTarget } from "./collection-target";
import type { ComponentState, Config } from "./useNativeCss";
export const INLINE_RULE_SYMBOL = Symbol("react-native-css.inlineRule");
@@ -49,9 +50,8 @@ export function updateRules(
for (const config of state.configs) {
const source = currentProps?.[config.source];
- const shallowTarget = Array.isArray(config.target)
- ? config.target[0]
- : config.target;
+ const shallowTarget =
+ typeof config.target === "object" ? config.target[0] : config.target;
guards.push(["a", config.source, source]);
if (shallowTarget) {
@@ -270,9 +270,12 @@ export function updateRules(
* Cache for reference equality.
*/
const getRuleVariation = weakFamily((rule: StyleRule) => {
- return weakFamily((config: Config): StyleRule => {
- return { ...rule, target: config.target };
- });
+ return weakFamily(
+ (config: Config): StyleRule => ({
+ ...rule,
+ target: collectionTarget(config),
+ }),
+ );
});
function pushInlineRule(
diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts
index b65165e5..2e5cbf31 100644
--- a/src/native/react/useNativeCss.ts
+++ b/src/native/react/useNativeCss.ts
@@ -8,7 +8,7 @@ import {
useState,
type ComponentType,
} from "react";
-import { Image, Pressable, StyleSheet, View } from "react-native";
+import { Image, Pressable, View } from "react-native";
import { VariableContext } from "react-native-css/native-internal";
@@ -18,6 +18,7 @@ import {
cleanupEffect,
ContainerContext,
hasChangedDependencies,
+ weakFamily,
type ContainerContextValue,
type Effect,
type Getter,
@@ -28,9 +29,9 @@ import { getStyledProps, stylesFamily } from "../styles";
import { updateRules } from "./rules";
export type Config = {
- source: string;
- target: string[] | string | false;
- nativeStyleMapping?: Record;
+ readonly source: string;
+ readonly target: readonly string[] | string | false;
+ readonly nativeStyleMapping?: Readonly>;
};
export type ComponentState = {
@@ -254,13 +255,16 @@ export function mappingToConfig(mapping: StyledConfiguration) {
});
}
-// Apply native Image fitting before normal, inline, and important props merge.
+// Apply native Image fitting once per computed props object, before normal, inline, and important props merge.
// The compiler's contentFit mapping remains available to Expo Image adapters.
-function adaptImageProps(props: Record | undefined) {
- if (!props || !("contentFit" in props)) return props;
- const { contentFit, style, ...rest } = props;
- return {
+const fittedImagePropsFamily = weakFamily(
+ ({ contentFit, style, ...rest }: Readonly>) => ({
...rest,
- style: { ...StyleSheet.flatten(style), objectFit: contentFit },
- };
+ // The computed style is a plain object, so it needs no flattening
+ style: Object.assign({}, style, { objectFit: contentFit }),
+ }),
+);
+
+function adaptImageProps(props: Readonly>) {
+ return "contentFit" in props ? fittedImagePropsFamily(props) : props;
}
diff --git a/src/native/react/usePassthrough.ts b/src/native/react/usePassthrough.ts
index fd80fb49..0474c6b7 100644
--- a/src/native/react/usePassthrough.ts
+++ b/src/native/react/usePassthrough.ts
@@ -29,7 +29,7 @@ export function usePassthrough(
let targetProps = props;
- if (Array.isArray(target)) {
+ if (typeof target !== "string") {
for (let i = 0; i < target.length - 1; i++) {
const prop = target[i]!;
const value = targetProps[prop];
diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..e5a9779b 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -8,6 +8,7 @@ import { Specificity as S } from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
import { applyValue, getDeepPath } from "../objects";
+import { collectsProps, targetPath } from "../react/collection-target";
import {
VAR_SYMBOL,
type Getter,
@@ -59,8 +60,8 @@ export function calculateProps(
if (typeof ruleTarget === "string") {
target = target[ruleTarget] ??= {};
- } else if (ruleTarget) {
- for (const path of ruleTarget) {
+ } else {
+ for (const path of targetPath(ruleTarget)) {
target = target[path] ??= {};
}
}
@@ -74,7 +75,7 @@ export function calculateProps(
transformStyles,
guards,
target,
- topLevelTarget,
+ collectsProps(ruleTarget) ? target : topLevelTarget,
);
}
}
diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts
index 6be03daa..d4d9ac86 100644
--- a/src/native/styles/index.ts
+++ b/src/native/styles/index.ts
@@ -2,6 +2,14 @@
import type { InlineVariable, StyleRule } from "react-native-css/compiler";
import { specificityCompareFn } from "react-native-css/utilities";
+import {
+ isNonArrayObject,
+ isPlainObject,
+ valueAtPath,
+ withDeepPaths,
+ withoutKeysAt,
+} from "../objects";
+import { collectionTarget, targetPath } from "../react/collection-target";
import { getInteractionHandler } from "../react/interaction";
import type { ComponentState, Config } from "../react/useNativeCss";
import {
@@ -12,6 +20,7 @@ import {
hoverFamily,
observable,
VAR_SYMBOL,
+ weakFamily,
type Effect,
type VariableContextValue,
} from "../reactivity";
@@ -188,8 +197,8 @@ export function getStyledProps(
state: ComponentState,
inline: Record | undefined | null,
adaptProps: (
- props: Record | undefined,
- ) => Record | undefined = (props) => props,
+ props: Readonly>,
+ ) => Readonly> = (props) => props,
) {
let result: Record | undefined;
@@ -197,29 +206,29 @@ export function getStyledProps(
// Each config merges a complete props object. Preserve its full target path
// before the next config merges unrelated inline props over that object.
- const computedTargets: { path: string[]; value: unknown }[] = [];
+ const computedTargets: {
+ readonly path: readonly string[];
+ readonly value: unknown;
+ }[] = [];
const consumedSources: string[] = [];
+ // The computed props are shared by every render, and every config is mapped before any merges, so each merge and each kept target sees every mapping
+ const normal = mapEveryConfig(state.configs, adaptProps, styledProps?.normal);
+ const important = mapEveryConfig(
+ state.configs,
+ adaptProps,
+ styledProps?.important,
+ );
+
for (const config of state.configs) {
- result = deepMergeConfig(
- config,
- nativeStyleMapping(config, adaptProps(styledProps?.normal)),
- inline,
- true,
- );
+ result = deepMergeConfig(config, normal, inline, true);
- if (styledProps?.important) {
- result = deepMergeConfig(
- config,
- result,
- nativeStyleMapping(config, adaptProps(styledProps.important)),
- );
+ if (important && config.target) {
+ result = deepMergeConfig(config, result, important);
}
if (result && config.target) {
- const path = Array.isArray(config.target)
- ? config.target
- : [config.target];
+ const path = targetPath(config.target);
let target = result;
for (const key of path.slice(0, -1)) target = target?.[key];
const key = path[path.length - 1];
@@ -295,6 +304,9 @@ export function getStyledProps(
// by later config iterations' Object.assign({}, left, right)
if (result) {
for (const { path, value } of computedTargets) {
+ if (valueAtPath(result, path) === value) {
+ continue;
+ }
let target = result;
for (const key of path.slice(0, -1)) {
const existing = target[key];
@@ -304,6 +316,15 @@ export function getStyledProps(
}
target[path[path.length - 1]!] = value;
}
+ // The props object is this render's own, so its keys settle in place, and a container below it, which the computed props may share, is copied
+ settleUnownedProps(
+ result,
+ writtenPropTreeFamily(state.configs),
+ normal,
+ inline ?? undefined,
+ important,
+ result,
+ );
for (const source of consumedSources) {
delete result[source];
}
@@ -322,10 +343,9 @@ export function getStyledProps(
* style from `left` gets overwritten. This function prevents that by only copying
* values that carry meaningful style information.
*
- * Empty object detection uses inline `for...in` instead of a separate function or
- * `Object.keys().length` — avoids both function call overhead and intermediate array
- * allocation on a hot path (called per-prop per-render). Consistent with how the
- * `["style"]` target path handles this via `filterCssVariables({})` returning `undefined`.
+ * `suppliesValue` decides which values count, as the props no config owns settle too.
+ * Consistent with how the `["style"]` target path handles this via
+ * `filterCssVariables({})` returning `undefined`.
*
* @param left - The computed NativeWind props (className-derived styles)
* @param right - The inline props from the component (guaranteed non-null by caller; may contain undefined values)
@@ -338,24 +358,27 @@ function mergeDefinedProps(
const result = left ? { ...left } : {};
for (const key in right) {
const value = right[key];
- if (value === undefined) continue;
- // Non-object values (strings, numbers, booleans, null, arrays): always assign
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- result[key] = value;
- continue;
- }
- // Plain object: assign only if non-empty. An empty style object (e.g.,
- // contentContainerStyle={{}}) has no properties to apply and should not
- // overwrite computed className styles. The for...in loop only executes
- // if the object has enumerable keys — empty objects are skipped.
- for (const _ in value) {
+ if (suppliesValue(value)) {
result[key] = value;
- break;
}
}
return result;
}
+// undefined and an empty style object, such as contentContainerStyle={{}}, supply nothing to merge over a computed value
+function suppliesValue(value: unknown): boolean {
+ if (value === undefined) {
+ return false;
+ }
+ if (!isNonArrayObject(value)) {
+ return true;
+ }
+ for (const _ in value) {
+ return true;
+ }
+ return false;
+}
+
function deepMergeConfig(
config: Config,
left: Record | undefined,
@@ -370,7 +393,7 @@ function deepMergeConfig(
let result: Record;
if (config.target) {
if (
- Array.isArray(config.target) &&
+ typeof config.target === "object" &&
config.target.length === 1 &&
config.target[0] === "style"
) {
@@ -394,13 +417,10 @@ function deepMergeConfig(
const leftStyle = left.style;
// For arrays or objects, check if we need to create a style array
- const leftIsObject =
- typeof leftStyle === "object" && !Array.isArray(leftStyle);
- const rightIsObject =
- typeof filteredRightStyle === "object" &&
- !Array.isArray(filteredRightStyle);
-
- if (leftIsObject && rightIsObject) {
+ if (
+ isNonArrayObject(leftStyle) &&
+ isNonArrayObject(filteredRightStyle)
+ ) {
if (hasNonOverlappingProperties(leftStyle, filteredRightStyle)) {
result.style = [leftStyle, filteredRightStyle];
} else {
@@ -465,7 +485,7 @@ function deepMergeConfig(
/**
* If target is a path, deep merge until we get to the last key
*/
- if (Array.isArray(config.target)) {
+ if (typeof config.target === "object") {
if (config.target.length > 1) {
const key = config.target[0]!;
result[key] = deepMergeConfig(
@@ -495,15 +515,7 @@ function deepMergeConfig(
}
} else if (left && finalKey in left) {
const leftValue = left[finalKey];
- const leftIsObj =
- typeof leftValue === "object" &&
- leftValue !== null &&
- !Array.isArray(leftValue);
- const rightIsObj =
- typeof rightValue === "object" &&
- rightValue !== null &&
- !Array.isArray(rightValue);
- if (leftIsObj && rightIsObj) {
+ if (isNonArrayObject(leftValue) && isNonArrayObject(rightValue)) {
if (hasNonOverlappingProperties(leftValue, rightValue)) {
result[finalKey] = [leftValue, rightValue];
} else {
@@ -543,63 +555,259 @@ function deepMergeConfig(
return result;
}
-function nativeStyleMapping(
- config: Config,
- props: Record | undefined,
+function mapEveryConfig(
+ configs: readonly Config[],
+ adaptProps: (
+ props: Readonly>,
+ ) => Readonly>,
+ props: Readonly> | undefined,
) {
if (!props) {
return props;
}
- if (!config.nativeStyleMapping) {
- if (config.target === false) delete props.style;
- return props;
- }
- let source: Record | undefined;
+ return mappedPropsFamily(configs)(adaptProps(props));
+}
- if (typeof config.target === "string") {
- source = props[config.target];
- } else if (config.target === false) {
- source = props["style"];
- } else {
- const tokens = [...config.target];
- const lastToken = tokens.pop()!;
-
- source = props;
- for (const token of tokens) {
- source = source[token];
- if (!source) {
- return props;
- }
- }
+// Mapping builds new objects, so it runs once per configs and computed props, and a render that changes neither maps nothing again
+const mappedPropsFamily = weakFamily((configs: readonly Config[]) => {
+ const mappingConfigs = configs.filter(hasNativeStyleMapping);
- source = source[lastToken];
- }
+ return mappingConfigs.length === 0
+ ? (props: Readonly>) => props
+ : weakFamily((props: Readonly>) =>
+ nativeStyleMapping(mappingConfigs, props),
+ );
+});
- if (!source) {
- return props;
- }
+type MappingConfig = Config & {
+ readonly nativeStyleMapping: NonNullable;
+};
- for (const [key, path] of Object.entries(config.nativeStyleMapping)) {
- const styleValue = source[key];
+function hasNativeStyleMapping(config: Config): config is MappingConfig {
+ return config.nativeStyleMapping !== undefined;
+}
- delete source[key];
+// Every mapping reads the props as computed, and every mapped key leaves its source before any destination is written, so no mapping reads what another wrote
+function nativeStyleMapping(
+ configs: readonly MappingConfig[],
+ props: Readonly>,
+) {
+ const sources = configs.flatMap((config) => {
+ const sourcePath = targetPath(collectionTarget(config));
+ const source = valueAtPath(props, sourcePath);
+ return isNonArrayObject(source) ? [{ config, sourcePath, source }] : [];
+ });
+
+ const stripped = sources.reduce(
+ (result, { config, sourcePath }) =>
+ withoutKeysAt(result, sourcePath, Object.keys(config.nativeStyleMapping)),
+ props,
+ );
+
+ return withDeepPaths(
+ stripped,
+ sources.flatMap(({ config, source }) =>
+ Object.entries(config.nativeStyleMapping).flatMap(([key, path]) => {
+ const value = source[key];
+ return value === undefined ? [] : [[path.split("."), value] as const];
+ }),
+ ),
+ );
+}
+
+interface WrittenPropTree {
+ readonly owned: boolean;
+ readonly holdsOwned: boolean;
+ readonly children: ReadonlyMap;
+}
+
+interface WrittenPath {
+ readonly path: readonly string[];
+ readonly owned: boolean;
+}
+
+// The paths the configs write: each target, which its config owns, each source, which getStyledProps strips, and each mapping destination; a component keeps one configs array, so this runs once per component
+const writtenPropTreeFamily = weakFamily(
+ (configs: readonly Config[]): WrittenPropTree =>
+ writtenPropTree([
+ ...configs.flatMap((config): WrittenPath[] =>
+ config.target === false
+ ? [{ path: [config.source], owned: true }]
+ : [
+ { path: [config.source], owned: true },
+ { path: targetPath(config.target), owned: true },
+ ],
+ ),
+ ...configs.flatMap((config): WrittenPath[] =>
+ Object.values(config.nativeStyleMapping ?? {}).map((destination) => ({
+ path: destination.split("."),
+ owned: false,
+ })),
+ ),
+ ]),
+);
- if (styleValue === undefined) {
- continue;
+function writtenPropTree(
+ paths: readonly WrittenPath[],
+ depth = 0,
+): WrittenPropTree {
+ const below = new Map();
+ for (const written of paths) {
+ const key = written.path[depth];
+ if (key !== undefined) {
+ below.set(key, [...(below.get(key) ?? []), written]);
}
+ }
+ return {
+ owned: paths.some(
+ (written) => written.owned && written.path.length === depth,
+ ),
+ holdsOwned: paths.some((written) => written.owned),
+ children: new Map(
+ [...below].map(([key, keyPaths]) => [
+ key,
+ writtenPropTree(keyPaths, depth + 1),
+ ]),
+ ),
+ };
+}
- let target = props;
- const tokens = path.split(".");
- const lastToken = tokens.pop();
- for (const token of tokens) {
- target[token] ??= {};
- target = target[token];
+// A prop no config owns settles the same way in any order of the configs: along a path the configs write it merges key by key, as main's merge does along a target's path, and a leaf takes its !important value, else its inline one, else its normal one, each whole
+function settleUnownedProps(
+ current: Readonly> | undefined,
+ tree: WrittenPropTree,
+ normal: Readonly> | undefined,
+ inline: Readonly> | undefined,
+ important: Readonly> | undefined,
+ writeInto?: Record,
+): Readonly> | undefined {
+ let settled = writeInto;
+ if (normal !== undefined) {
+ for (const key in normal) {
+ settled = withSettledKey(
+ settled,
+ key,
+ current,
+ tree,
+ normal,
+ inline,
+ important,
+ );
+ }
+ }
+ if (important !== undefined) {
+ for (const key in important) {
+ if (normal === undefined || !(key in normal)) {
+ settled = withSettledKey(
+ settled,
+ key,
+ current,
+ tree,
+ normal,
+ inline,
+ important,
+ );
+ }
}
+ }
+ return settled ?? current;
+}
- target[lastToken!] = styleValue;
+// A settled object is copied on its first change only, so a render that settles nothing new allocates nothing
+function withSettledKey(
+ settled: Record | undefined,
+ key: string,
+ current: Readonly> | undefined,
+ tree: WrittenPropTree,
+ normal: Readonly> | undefined,
+ inline: Readonly> | undefined,
+ important: Readonly> | undefined,
+): Record | undefined {
+ const branch = tree.children.get(key);
+ if (branch?.owned) {
+ return settled;
+ }
+ const value =
+ branch === undefined || branch.children.size === 0
+ ? settledLeaf(normal?.[key], inline?.[key], important?.[key])
+ : settledContainer(
+ current?.[key],
+ branch,
+ normal?.[key],
+ inline?.[key],
+ important?.[key],
+ );
+ if (current !== undefined && key in current && current[key] === value) {
+ return settled;
}
+ const target = settled ?? { ...current };
+ target[key] = value;
+ return target;
+}
+
+function settledLeaf(
+ normal: unknown,
+ inline: unknown,
+ important: unknown,
+): unknown {
+ if (important !== undefined) {
+ return important;
+ }
+ return suppliesValue(inline) ? inline : normal;
+}
- if (config.target === false) delete props.style;
- return props;
+// An inline value that is no object replaces the normal value whole, as main's merge does, and an !important value replaces it
+function settledContainer(
+ current: unknown,
+ branch: WrittenPropTree,
+ normal: unknown,
+ inline: unknown,
+ important: unknown,
+): unknown {
+ if (inline !== undefined && !isPlainObject(inline)) {
+ return important ?? inline;
+ }
+ if (important !== undefined && !isPlainObject(important)) {
+ return important;
+ }
+ if (branch.holdsOwned) {
+ return settleUnownedProps(
+ isPlainObject(current) ? current : undefined,
+ branch,
+ isPlainObject(normal) ? normal : undefined,
+ isPlainObject(inline) ? inline : undefined,
+ isPlainObject(important) ? important : undefined,
+ );
+ }
+ return settledMappedContainerFamily(branch)(valueKey(normal))(
+ valueKey(inline),
+ )(valueKey(important));
}
+
+// A container only mappings write holds nothing a target restores, so it depends on its three values alone, and keeps its identity while they keep theirs
+const NO_VALUE = Object.freeze({});
+
+function valueKey(value: unknown): object {
+ return isPlainObject(value) ? value : NO_VALUE;
+}
+
+function recordOf(key: object): Readonly> | undefined {
+ return key === NO_VALUE || !isPlainObject(key) ? undefined : key;
+}
+
+const settledMappedContainerFamily = weakFamily((branch: WrittenPropTree) =>
+ weakFamily((normal: object) =>
+ weakFamily((inline: object) =>
+ weakFamily((important: object) =>
+ settleUnownedProps(
+ recordOf(inline),
+ branch,
+ recordOf(normal),
+ recordOf(inline),
+ recordOf(important),
+ ),
+ ),
+ ),
+ ),
+);