diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx
index 4ec527a9..729458aa 100644
--- a/src/__tests__/native/components.test.tsx
+++ b/src/__tests__/native/components.test.tsx
@@ -1,19 +1,24 @@
+import { memo } from "react";
import {
+ Animated,
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 { 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 } from "react-native-css/native";
import type {
StyledConfiguration,
StyledProps,
@@ -151,3 +156,494 @@ 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",
+ });
+});
+
+test.each([
+ ["an inline style object", "style", "style.borderColor", { margin: 1 }],
+ [
+ "an inline style function",
+ "style",
+ "style.borderColor",
+ () => ({ margin: 1 }),
+ ],
+ [
+ "an inline style holding an Animated value",
+ "style",
+ "style.borderColor",
+ { opacity: new Animated.Value(0.5) },
+ ],
+ [
+ "an inline ripple",
+ "android_ripple",
+ "android_ripple.color",
+ { borderless: true },
+ ],
+] as const)(
+ "%s reaches the component as it came beside a target:false mapping into it",
+ (_, prop, destination, inlineValue) => {
+ for (const css of [
+ `.label { color: blue; }`,
+ `.label { color: blue !important; }`,
+ ]) {
+ 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: inlineValue,
+ });
+ unmount();
+ }
+ },
+);
+
+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" },
+ ]);
+});
+
+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,
+ });
+ },
+);
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..666c259d
--- /dev/null
+++ b/src/__tests__/native/objects.test.ts
@@ -0,0 +1,125 @@
+import {
+ valueAtPath,
+ withDeepPath,
+ withoutKeys,
+ withoutKeysAt,
+} from "../../native/objects";
+
+describe("withDeepPath", () => {
+ 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 = withDeepPath(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(withDeepPath({}, ["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(withDeepPath(source, ["style", "opacity"], 0.5)).toStrictEqual({
+ [marker]: true,
+ style: { [marker]: true, color: "red", opacity: 0.5 },
+ });
+ });
+
+ test("returns its source for an empty path", () => {
+ const source = { style: { color: "red" } };
+
+ expect(withDeepPath(source, [], "blue")).toBe(source);
+ });
+
+ test("gives the same result for the same input, however often it runs", () => {
+ const source = { style: { color: "red" } };
+
+ const first = withDeepPath(source, ["style", "opacity"], 0.5);
+ const second = withDeepPath(source, ["style", "opacity"], 0.5);
+
+ expect(first).toStrictEqual(second);
+ expect(first).not.toBe(second);
+ expect(source).toStrictEqual({ style: { color: "red" } });
+ });
+});
+
+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..d4401b6e 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 | 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..2f9ca033 100644
--- a/src/native/objects.ts
+++ b/src/native/objects.ts
@@ -12,17 +12,7 @@ export function getDeepPath(source: any, paths: string | string[] | false) {
}
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;
+ return valueAtPath(source, paths);
} else if (transformKeys.has(paths)) {
return Array.isArray(source?.transform)
? source.transform.find((t: any) => t[paths] !== undefined)
@@ -32,6 +22,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 +111,59 @@ export function setDeepPath(
return target;
}
+
+export function isNonArrayObject(
+ value: unknown,
+): value is Readonly> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function withDeepPath(
+ source: Readonly>,
+ paths: readonly string[],
+ value: unknown,
+): Readonly> {
+ const [path, ...rest] = paths;
+ if (path === undefined) {
+ return source;
+ }
+
+ const child = source[path];
+ return {
+ ...source,
+ [path]:
+ rest.length === 0
+ ? value
+ : withDeepPath(isNonArrayObject(child) ? child : {}, rest, value),
+ };
+}
+
+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..e416f12a
--- /dev/null
+++ b/src/native/react/collection-target.ts
@@ -0,0 +1,22 @@
+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: RuleTarget): readonly string[] {
+ if (typeof target === "string") {
+ return [target];
+ }
+
+ return collectsProps(target) ? [target.collect] : target;
+}
+
+export function collectsProps(target: RuleTarget): target is CollectedTarget {
+ return typeof target === "object" && "collect" in target;
+}
diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts
index e0fe8d1a..0ec2cca5 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");
@@ -270,9 +271,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..71b512e9 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,
@@ -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/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..7cf9b4d6 100644
--- a/src/native/styles/index.ts
+++ b/src/native/styles/index.ts
@@ -2,6 +2,13 @@
import type { InlineVariable, StyleRule } from "react-native-css/compiler";
import { specificityCompareFn } from "react-native-css/utilities";
+import {
+ isNonArrayObject,
+ valueAtPath,
+ withDeepPath,
+ 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 +19,7 @@ import {
hoverFamily,
observable,
VAR_SYMBOL,
+ weakFamily,
type Effect,
type VariableContextValue,
} from "../reactivity";
@@ -188,8 +196,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 +205,28 @@ 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: { path: readonly string[]; 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,
- );
-
- if (styledProps?.important) {
- result = deepMergeConfig(
- config,
- result,
- nativeStyleMapping(config, adaptProps(styledProps.important)),
- );
+ result = deepMergeConfig(config, normal, inline, true);
+
+ if (important) {
+ result = config.target
+ ? deepMergeConfig(config, result, important)
+ : mergeUnownedProps(result, important, inline);
}
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];
@@ -340,7 +347,7 @@ function mergeDefinedProps(
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)) {
+ if (!isNonArrayObject(value)) {
result[key] = value;
continue;
}
@@ -394,13 +401,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 {
@@ -495,15 +499,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 +539,105 @@ 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;
-
- 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;
- }
- }
+ return mappedPropsFamily(configs)(adaptProps(props));
+}
- source = source[lastToken];
- }
+// 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);
- if (!source) {
- return props;
- }
+ return mappingConfigs.length === 0
+ ? (props: Readonly>) => props
+ : weakFamily((props: Readonly>) =>
+ nativeStyleMapping(mappingConfigs, props),
+ );
+});
+
+type MappingConfig = Readonly & {
+ readonly nativeStyleMapping: Readonly<
+ NonNullable
+ >;
+};
+
+function hasNativeStyleMapping(
+ config: Readonly,
+): config is MappingConfig {
+ return config.nativeStyleMapping !== undefined;
+}
- for (const [key, path] of Object.entries(config.nativeStyleMapping)) {
- const styleValue = 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 sources.reduce(
+ (result, { config, source }) =>
+ Object.entries(config.nativeStyleMapping).reduce(
+ (written, [key, path]) => {
+ const value = source[key];
+ return value === undefined
+ ? written
+ : withDeepPath(written, path.split("."), value);
+ },
+ result,
+ ),
+ stripped,
+ );
+}
- delete source[key];
+// A config with no target owns none of the props its mapping writes, so an inline value of one stands, as for a normal declaration, and the config's normal and !important objects meet key by key
+function mergeUnownedProps(
+ merged: Readonly>,
+ important: Readonly>,
+ inline: Readonly> | undefined | null,
+) {
+ const result: Record = { ...merged };
- if (styleValue === undefined) {
+ for (const [key, value] of Object.entries(important)) {
+ if (value === undefined || suppliesProp(inline, key)) {
continue;
}
-
- let target = props;
- const tokens = path.split(".");
- const lastToken = tokens.pop();
- for (const token of tokens) {
- target[token] ??= {};
- target = target[token];
- }
-
- target[lastToken!] = styleValue;
+ const current = result[key];
+ result[key] =
+ isNonArrayObject(current) && isNonArrayObject(value)
+ ? { ...current, ...value }
+ : value;
}
- if (config.target === false) delete props.style;
- return props;
+ return result;
+}
+
+// An inline value counts as mergeDefinedProps counts it: not undefined, and no empty plain object
+function suppliesProp(
+ inline: Readonly> | undefined | null,
+ key: string,
+) {
+ const value = inline?.[key];
+ return (
+ value !== undefined &&
+ !(isNonArrayObject(value) && Object.keys(value).length === 0)
+ );
}