From ad8f9f847aacc8b023eb42f412ed0f0ee0aa5c88 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 13:59:53 +0300 Subject: [PATCH] fix(native): map native styles onto new objects, leaving the computed props untouched nativeStyleMapping drained the computed props in place. Those props are cached and shared by every render, and an Image with object-fit maps a copy whose nested objects are still the cached ones, so a mapped value was lost when another config followed it. Each config now maps the props the config before it produced, and the mapping copies only the objects it changes. --- src/__tests__/native/image-fit.test.tsx | 35 ++++++++ src/__tests__/native/objects.test.ts | 52 ++++++++++++ src/__tests__/native/styled.test.ios.tsx | 33 ++++++++ src/native/objects.ts | 39 +++++++++ src/native/styles/index.ts | 102 ++++++++++------------- 5 files changed, 205 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/native/objects.test.ts 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; }