diff --git a/src/__tests__/native/image-fit.test.tsx b/src/__tests__/native/image-fit.test.tsx
index de49f163..44730ffc 100644
--- a/src/__tests__/native/image-fit.test.tsx
+++ b/src/__tests__/native/image-fit.test.tsx
@@ -85,3 +85,38 @@ 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");
+ },
+ );
+});
diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts
new file mode 100644
index 00000000..30431193
--- /dev/null
+++ b/src/__tests__/native/objects.test.ts
@@ -0,0 +1,52 @@
+import { withDeepPath, withoutKeys } 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("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", () => {
+ const source = { style: { color: "red" } };
+
+ expect(withDeepPath(source, ["style", "opacity"], 0.5)).toStrictEqual(
+ withDeepPath(source, ["style", "opacity"], 0.5),
+ );
+ });
+});
+
+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("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 });
+ });
+});
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/native/objects.ts b/src/native/objects.ts
index 12e69ccd..ec2315fd 100644
--- a/src/native/objects.ts
+++ b/src/native/objects.ts
@@ -107,3 +107,42 @@ export function setDeepPath(
return target;
}
+
+export function isPlainObject(
+ 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(isPlainObject(child) ? child : {}, rest, value),
+ };
+}
+
+export function withoutKeys(
+ source: Readonly>,
+ keys: readonly string[],
+): Readonly> {
+ if (!keys.some((key) => key in source)) {
+ return source;
+ }
+
+ return Object.fromEntries(
+ Object.entries(source).filter(([key]) => !keys.includes(key)),
+ );
+}
diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts
index 6be03daa..b966b4dc 100644
--- a/src/native/styles/index.ts
+++ b/src/native/styles/index.ts
@@ -2,6 +2,12 @@
import type { InlineVariable, StyleRule } from "react-native-css/compiler";
import { specificityCompareFn } from "react-native-css/utilities";
+import {
+ getDeepPath,
+ isPlainObject,
+ withDeepPath,
+ withoutKeys,
+} from "../objects";
import { getInteractionHandler } from "../react/interaction";
import type { ComponentState, Config } from "../react/useNativeCss";
import {
@@ -200,20 +206,17 @@ export function getStyledProps(
const computedTargets: { path: string[]; value: unknown }[] = [];
const consumedSources: string[] = [];
+ // The computed props are shared by every render, so each config maps the props the config before it produced
+ let normal = adaptProps(styledProps?.normal);
+ let important = 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)),
- );
+ normal = nativeStyleMapping(config, normal);
+ result = deepMergeConfig(config, normal, inline, true);
+
+ if (important) {
+ important = nativeStyleMapping(config, important);
+ result = deepMergeConfig(config, result, important);
}
if (result && config.target) {
@@ -545,61 +548,48 @@ function deepMergeConfig(
function nativeStyleMapping(
config: Config,
- props: Record | undefined,
+ 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;
- }
- }
+ const mapped = config.nativeStyleMapping
+ ? mapNativeStyles(config, config.nativeStyleMapping, props)
+ : props;
- source = source[lastToken];
- }
+ return config.target === false ? withoutKeys(mapped, ["style"]) : mapped;
+}
- if (!source) {
+function mapNativeStyles(
+ config: Config,
+ mapping: Readonly>,
+ props: Readonly>,
+) {
+ const sourcePath =
+ config.target === false
+ ? ["style"]
+ : typeof config.target === "string"
+ ? [config.target]
+ : config.target;
+ const source: unknown = getDeepPath(props, sourcePath);
+
+ if (!isPlainObject(source)) {
return props;
}
- for (const [key, path] of Object.entries(config.nativeStyleMapping)) {
- const styleValue = source[key];
-
- delete source[key];
+ const remaining = withoutKeys(source, Object.keys(mapping));
+ let result =
+ config.target === false || remaining === source
+ ? props
+ : withDeepPath(props, sourcePath, remaining);
- if (styleValue === undefined) {
- continue;
- }
-
- let target = props;
- const tokens = path.split(".");
- const lastToken = tokens.pop();
- for (const token of tokens) {
- target[token] ??= {};
- target = target[token];
+ for (const [key, path] of Object.entries(mapping)) {
+ const value = source[key];
+ if (value !== undefined) {
+ result = withDeepPath(result, path.split("."), value);
}
-
- target[lastToken!] = styleValue;
}
- if (config.target === false) delete props.style;
- return props;
+ return result;
}