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; }