From 7cfbc420c85bca9954eb499fbe8b85b5a7ecfc43 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 05:14:09 +0300 Subject: [PATCH 01/13] fix(native): a target:false config gets its own scratch space, not `style` A target:false config writes no style prop: nativeStyleMapping moves its declarations into real props. Until then they sat in `style`, a real target another config can own. A component carrying both kinds of config put both sets of declarations in one object, and the style-target config's declarations were then deleted with `style` after the mapping. The declarations now collect under the config's own source key. No other config shares that key, and it never ships, so `style` belongs only to whichever config targets it and nothing deletes it. A declaration the mapping does not name is dropped, as a config that owns no style prop implies. --- src/__tests__/native/components.test.tsx | 139 ++++++++++++++++++++++- src/native/react/rules.ts | 6 +- src/native/styles/index.ts | 4 +- 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 4ec527a9..9660975f 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -6,6 +6,7 @@ import { View, type ButtonProps, type TextInputProps, + type ViewProps, } from "react-native"; import { render } from "@testing-library/react-native"; @@ -13,7 +14,7 @@ import { copyComponentProperties } from "react-native-css/components/copyCompone 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 +152,139 @@ 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"); +}); diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..dc0386b8 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -271,7 +271,11 @@ export function updateRules( */ const getRuleVariation = weakFamily((rule: StyleRule) => { return weakFamily((config: Config): StyleRule => { - return { ...rule, target: config.target }; + // A target:false config collects its declarations under its own source key until nativeStyleMapping moves them; style belongs to whichever config targets it + return { + ...rule, + target: config.target === false ? [config.source] : config.target, + }; }); }); diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..5e7d2569 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -551,7 +551,6 @@ function nativeStyleMapping( return props; } if (!config.nativeStyleMapping) { - if (config.target === false) delete props.style; return props; } @@ -560,7 +559,7 @@ function nativeStyleMapping( if (typeof config.target === "string") { source = props[config.target]; } else if (config.target === false) { - source = props["style"]; + source = props[config.source]; } else { const tokens = [...config.target]; const lastToken = tokens.pop()!; @@ -600,6 +599,5 @@ function nativeStyleMapping( target[lastToken!] = styleValue; } - if (config.target === false) delete props.style; return props; } From 7f2c1449e088eebe47185f627b1a0cbdd0acfbbf Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 13:59:53 +0300 Subject: [PATCH 02/13] fix(native): map every config before merging, on new objects that leave 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 target:false config's own scratch space was drained in the cache and its mapped value lost from the second render on. The mapping now builds new objects along the paths it changes, every config is mapped before any merges, so each merge and each kept target sees every mapping, and one helper names where a config's declarations collect for the rule variation and the mapping alike. --- src/__tests__/native/components.test.tsx | 110 ++++++++++++++++++++++- src/__tests__/native/image-fit.test.tsx | 35 ++++++++ src/__tests__/native/objects.test.ts | 65 ++++++++++++++ src/__tests__/native/styled.test.ios.tsx | 33 +++++++ src/compiler/compiler.types.ts | 2 +- src/native/objects.ts | 37 ++++++++ src/native/react/collection-target.ts | 6 ++ src/native/react/rules.ts | 12 +-- src/native/styles/index.ts | 98 +++++++++----------- 9 files changed, 334 insertions(+), 64 deletions(-) create mode 100644 src/__tests__/native/objects.test.ts create mode 100644 src/native/react/collection-target.ts diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 9660975f..aa217ca0 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -1,6 +1,8 @@ import { Button as RNButton, + Image as RNImage, KeyboardAvoidingView as RNKeyboardAvoidingView, + Text as RNText, TextInput as RNTextInput, StyleSheet, View, @@ -9,7 +11,7 @@ import { 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"; @@ -288,3 +290,109 @@ test("a declaration no mapping entry names is dropped, not moved into style", () ).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 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" }); + } +}); diff --git a/src/__tests__/native/image-fit.test.tsx b/src/__tests__/native/image-fit.test.tsx index de49f163..b8dee402 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..6bc7b06e --- /dev/null +++ b/src/__tests__/native/objects.test.ts @@ -0,0 +1,65 @@ +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, 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 }); + }); +}); 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..111da021 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[]; /** * Conditionals diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..07bc6101 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -107,3 +107,40 @@ 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); +} diff --git a/src/native/react/collection-target.ts b/src/native/react/collection-target.ts new file mode 100644 index 00000000..5a285331 --- /dev/null +++ b/src/native/react/collection-target.ts @@ -0,0 +1,6 @@ +import type { Config } from "./useNativeCss"; + +// A target:false config collects its declarations under its own source prop, which nativeStyleMapping drains; style belongs to the configs that target it +export function collectionTarget(config: Config): string | string[] { + return config.target === false ? [config.source] : config.target; +} diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index dc0386b8..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,13 +271,12 @@ export function updateRules( * Cache for reference equality. */ const getRuleVariation = weakFamily((rule: StyleRule) => { - return weakFamily((config: Config): StyleRule => { - // A target:false config collects its declarations under its own source key until nativeStyleMapping moves them; style belongs to whichever config targets it - return { + return weakFamily( + (config: Config): StyleRule => ({ ...rule, - target: config.target === false ? [config.source] : config.target, - }; - }); + target: collectionTarget(config), + }), + ); }); function pushInlineRule( diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 5e7d2569..0ff70e7f 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 { + getDeepPath, + isNonArrayObject, + withDeepPath, + withoutKeys, +} from "../objects"; +import { collectionTarget } from "../react/collection-target"; import { getInteractionHandler } from "../react/interaction"; import type { ComponentState, Config } from "../react/useNativeCss"; import { @@ -200,20 +207,18 @@ export function getStyledProps( const computedTargets: { path: 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 = deepMergeConfig(config, result, important); } if (result && config.target) { @@ -543,61 +548,42 @@ function deepMergeConfig( return result; } +function mapEveryConfig( + configs: readonly Config[], + props: Readonly> | undefined, +) { + return configs.reduce( + (mapped, config) => nativeStyleMapping(config, mapped), + props, + ); +} + function nativeStyleMapping( config: Config, - props: Record | undefined, + props: Readonly> | undefined, ) { - if (!props) { - return props; - } - if (!config.nativeStyleMapping) { + if (!props || !config.nativeStyleMapping) { return props; } - let source: Record | undefined; + const collection = collectionTarget(config); + const sourcePath = typeof collection === "string" ? [collection] : collection; + const source: unknown = getDeepPath(props, sourcePath); - if (typeof config.target === "string") { - source = props[config.target]; - } else if (config.target === false) { - source = props[config.source]; - } else { - const tokens = [...config.target]; - const lastToken = tokens.pop()!; - - source = props; - for (const token of tokens) { - source = source[token]; - if (!source) { - return props; - } - } - - source = source[lastToken]; - } - - if (!source) { + if (!isNonArrayObject(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(config.nativeStyleMapping)); + let result = + 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(config.nativeStyleMapping)) { + const value = source[key]; + if (value !== undefined) { + result = withDeepPath(result, path.split("."), value); } - - target[lastToken!] = styleValue; } - return props; + return result; } From dd55a097cd231f18fbef579b6020977feba66e01 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 17:33:01 +0300 Subject: [PATCH 03/13] fix(native): map each computed props object once, and merge a mapped prop with its inline value Mapping builds new objects, so mapping on every render handed a memoized component a new style each time. The mapped props are now kept per configs and computed props object, and the Image adapter per computed props object, so a render that changes neither hands React the same objects. A mapping into a key of another prop, such as `style.borderColor` from a target:false config, now merges that prop with its inline value as a config that targets the prop does, instead of losing it to an inline `style`. The three copies of the non-array object check use `isNonArrayObject`, and `calculateProps` drops a branch condition that was always true. --- src/__tests__/native/components.test.tsx | 62 ++++++++++++++ src/__tests__/native/image-fit.test.tsx | 17 ++++ src/native/react/collection-target.ts | 2 +- src/native/react/useNativeCss.ts | 9 +- src/native/styles/calculate-props.ts | 2 +- src/native/styles/index.ts | 101 ++++++++++++++++------- 6 files changed, 159 insertions(+), 34 deletions(-) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index aa217ca0..065fd5f9 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Button as RNButton, Image as RNImage, @@ -7,8 +8,10 @@ import { StyleSheet, View, type ButtonProps, + type StyleProp, type TextInputProps, type ViewProps, + type ViewStyle, } from "react-native"; import { render, screen } from "@testing-library/react-native"; @@ -372,6 +375,65 @@ test("a mapping into an earlier config's target keeps its value on every render" } }); +test("a target:false mapping into style keeps its value beside an inline style", () => { + registerCSS(`.label { color: blue; }`); + const Bordered = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + const inlineStyles: readonly (readonly [StyleProp, ViewStyle])[] = + [ + [undefined, { borderColor: "#00f" }], + [{ margin: 1 }, { borderColor: "#00f", margin: 1 }], + [ + [{ margin: 1 }, { padding: 2 }], + { borderColor: "#00f", margin: 1, padding: 2 }, + ], + [{ borderColor: "red" }, { borderColor: "red" }], + ]; + + render(); + for (const [style, expected] of inlineStyles) { + screen.rerender( + , + ); + expect( + StyleSheet.flatten(screen.getByTestId(testID).props.style), + ).toStrictEqual(expected); + } +}); + +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, { diff --git a/src/__tests__/native/image-fit.test.tsx b/src/__tests__/native/image-fit.test.tsx index b8dee402..2b608300 100644 --- a/src/__tests__/native/image-fit.test.tsx +++ b/src/__tests__/native/image-fit.test.tsx @@ -119,4 +119,21 @@ describe("a mapping on an image with object-fit", () => { 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/native/react/collection-target.ts b/src/native/react/collection-target.ts index 5a285331..f42eac68 100644 --- a/src/native/react/collection-target.ts +++ b/src/native/react/collection-target.ts @@ -1,6 +1,6 @@ import type { Config } from "./useNativeCss"; -// A target:false config collects its declarations under its own source prop, which nativeStyleMapping drains; style belongs to the configs that target it +// A target:false config collects its declarations 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): string | string[] { return config.target === false ? [config.source] : config.target; } diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index b65165e5..ba5a34f2 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -18,6 +18,7 @@ import { cleanupEffect, ContainerContext, hasChangedDependencies, + weakFamily, type ContainerContextValue, type Effect, type Getter, @@ -254,13 +255,13 @@ 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 adaptImageProps = weakFamily((props: Readonly>) => { + if (!("contentFit" in props)) return props; const { contentFit, style, ...rest } = props; return { ...rest, style: { ...StyleSheet.flatten(style), objectFit: contentFit }, }; -} +}); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..40756045 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -59,7 +59,7 @@ export function calculateProps( if (typeof ruleTarget === "string") { target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { + } else { for (const path of ruleTarget) { target = target[path] ??= {}; } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 0ff70e7f..9c41d54d 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -19,6 +19,7 @@ import { hoverFamily, observable, VAR_SYMBOL, + weakFamily, type Effect, type VariableContextValue, } from "../reactivity"; @@ -195,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; @@ -208,13 +209,14 @@ export function getStyledProps( 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 normal = mapEveryConfig(state.configs, adaptProps, styledProps?.normal); const important = mapEveryConfig( state.configs, - adaptProps(styledProps?.important), + adaptProps, + styledProps?.important, ); - for (const config of state.configs) { + for (const config of mergeConfigs(state.configs)) { result = deepMergeConfig(config, normal, inline, true); if (important) { @@ -345,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; } @@ -399,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 { @@ -500,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 { @@ -550,19 +541,73 @@ function deepMergeConfig( function mapEveryConfig( configs: readonly Config[], + adaptProps: ( + props: Readonly>, + ) => Readonly>, props: Readonly> | undefined, ) { - return configs.reduce( - (mapped, config) => nativeStyleMapping(config, mapped), - props, - ); + if (!props) { + return props; + } + + const adapted = adaptProps(props); + return configs.some(hasNativeStyleMapping) + ? mappedPropsFamily(configs)(adapted) + : adapted; +} + +// Mapping builds new objects, so it runs once per configs and computed props, and a render that changes neither hands React the same objects +const mappedPropsFamily = weakFamily((configs: readonly Config[]) => + weakFamily((props: Readonly>) => + configs.reduce( + (mapped, config) => nativeStyleMapping(config, mapped), + props, + ), + ), +); + +function mergeConfigs(configs: readonly Config[]) { + return configs.some(hasNativeStyleMapping) + ? mergeConfigsFamily(configs) + : configs; +} + +// A mapping into a key of another prop merges that prop with its inline value, as a config that targets the prop does +const mergeConfigsFamily = weakFamily( + (configs: readonly Config[]): readonly Config[] => [ + ...configs.flatMap(mappedPropConfigs), + ...configs, + ], +); + +function mappedPropConfigs(config: Config): Config[] { + const targetProp = Array.isArray(config.target) + ? config.target[0] + : config.target; + const mappedProps = new Set(); + + for (const path of Object.values(config.nativeStyleMapping ?? {})) { + const [prop, ...keys] = path.split("."); + if (prop !== undefined && keys.length > 0 && prop !== targetProp) { + mappedProps.add(prop); + } + } + + return Array.from(mappedProps, (prop) => ({ + source: config.source, + target: [prop], + })); +} + +function hasNativeStyleMapping(config: Config) { + return config.nativeStyleMapping !== undefined; } function nativeStyleMapping( config: Config, - props: Readonly> | undefined, + props: Readonly>, ) { - if (!props || !config.nativeStyleMapping) { + if (!config.nativeStyleMapping) { return props; } From 4555a790fd29b3640068cc3ea78f9d553b7359d5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:50:27 +0300 Subject: [PATCH 04/13] refactor(native): read the Image adapter's props without any --- src/native/react/useNativeCss.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index ba5a34f2..e7fb1af4 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -8,12 +8,13 @@ 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"; import type { StyledConfiguration } from "../../runtime.types"; import { testGuards, type RenderGuard } from "../conditions/guards"; +import { isNonArrayObject } from "../objects"; import { cleanupEffect, ContainerContext, @@ -257,11 +258,17 @@ export function mappingToConfig(mapping: StyledConfiguration) { // 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. -const adaptImageProps = weakFamily((props: Readonly>) => { - if (!("contentFit" in props)) return props; - const { contentFit, style, ...rest } = props; - return { - ...rest, - style: { ...StyleSheet.flatten(style), objectFit: contentFit }, - }; -}); +const adaptImageProps = weakFamily( + (props: Readonly>) => { + if (!("contentFit" in props)) return props; + const { contentFit, style, ...rest } = props; + // The computed style is a plain object, so it needs no flattening + return { + ...rest, + style: { + ...(isNonArrayObject(style) ? style : undefined), + objectFit: contentFit, + }, + }; + }, +); From 9910354578dd25cef533c49fb42f73b84fd7639e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:51:07 +0300 Subject: [PATCH 05/13] fix(native): keep a target:false class's own props under it, and leave an inline prop unmerged A target:false config's rules now collect the props their declarations name, such as caret-color's cursorColor, under the config's scratch key with its styles, so only a mapping ships them, as the config's contract says. A mapping into a key of another prop no longer merges that prop with its inline value: the merge treated every prop as a style, so an inline android_ripple became an array, an inline style function ended up in an array, and an inline Animated value was copied. --- src/__tests__/native/components.test.tsx | 79 +++++++++++++++++------- src/compiler/compiler.types.ts | 2 + src/native/react/rules.ts | 1 + src/native/styles/calculate-props.ts | 2 +- src/native/styles/index.ts | 35 +---------- 5 files changed, 63 insertions(+), 56 deletions(-) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 065fd5f9..80effc8a 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -8,10 +8,8 @@ import { StyleSheet, View, type ButtonProps, - type StyleProp, type TextInputProps, type ViewProps, - type ViewStyle, } from "react-native"; import { render, screen } from "@testing-library/react-native"; @@ -375,7 +373,7 @@ test("a mapping into an earlier config's target keeps its value on every render" } }); -test("a target:false mapping into style keeps its value beside an inline style", () => { +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: { @@ -383,26 +381,12 @@ test("a target:false mapping into style keeps its value beside an inline style", nativeStyleMapping: { color: "style.borderColor" }, }, }); - const inlineStyles: readonly (readonly [StyleProp, ViewStyle])[] = - [ - [undefined, { borderColor: "#00f" }], - [{ margin: 1 }, { borderColor: "#00f", margin: 1 }], - [ - [{ margin: 1 }, { padding: 2 }], - { borderColor: "#00f", margin: 1, padding: 2 }, - ], - [{ borderColor: "red" }, { borderColor: "red" }], - ]; render(); - for (const [style, expected] of inlineStyles) { - screen.rerender( - , - ); - expect( - StyleSheet.flatten(screen.getByTestId(testID).props.style), - ).toStrictEqual(expected); - } + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderColor: "#00f", + }); }); test("a mapping builds its props once while the classes stay the same", () => { @@ -458,3 +442,56 @@ test("a target:false config follows its class as it is set, removed and set agai 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/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 111da021..7757dab2 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -74,6 +74,8 @@ export interface StyleRule { // Target override target?: string | string[]; + // The props a declaration names land under the target too, as a target:false config collects them + propsUnderTarget?: true; /** * Conditionals diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index 0ec2cca5..dc4f8be1 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -275,6 +275,7 @@ const getRuleVariation = weakFamily((rule: StyleRule) => { (config: Config): StyleRule => ({ ...rule, target: collectionTarget(config), + ...(config.target === false && { propsUnderTarget: true }), }), ); }); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 40756045..88be2f17 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -74,7 +74,7 @@ export function calculateProps( transformStyles, guards, target, - topLevelTarget, + rule.propsUnderTarget ? target : topLevelTarget, ); } } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 9c41d54d..391e293e 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -216,7 +216,7 @@ export function getStyledProps( styledProps?.important, ); - for (const config of mergeConfigs(state.configs)) { + for (const config of state.configs) { result = deepMergeConfig(config, normal, inline, true); if (important) { @@ -566,39 +566,6 @@ const mappedPropsFamily = weakFamily((configs: readonly Config[]) => ), ); -function mergeConfigs(configs: readonly Config[]) { - return configs.some(hasNativeStyleMapping) - ? mergeConfigsFamily(configs) - : configs; -} - -// A mapping into a key of another prop merges that prop with its inline value, as a config that targets the prop does -const mergeConfigsFamily = weakFamily( - (configs: readonly Config[]): readonly Config[] => [ - ...configs.flatMap(mappedPropConfigs), - ...configs, - ], -); - -function mappedPropConfigs(config: Config): Config[] { - const targetProp = Array.isArray(config.target) - ? config.target[0] - : config.target; - const mappedProps = new Set(); - - for (const path of Object.values(config.nativeStyleMapping ?? {})) { - const [prop, ...keys] = path.split("."); - if (prop !== undefined && keys.length > 0 && prop !== targetProp) { - mappedProps.add(prop); - } - } - - return Array.from(mappedProps, (prop) => ({ - source: config.source, - target: [prop], - })); -} - function hasNativeStyleMapping(config: Config) { return config.nativeStyleMapping !== undefined; } From b65afa8d7270b761f1ab72248072ad06dc37ed91 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:59:09 +0300 Subject: [PATCH 06/13] refactor(native): build the Image adapter's style with Object.assign, which skips a missing style --- src/native/react/useNativeCss.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index e7fb1af4..92e805a4 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -14,7 +14,6 @@ import { VariableContext } from "react-native-css/native-internal"; import type { StyledConfiguration } from "../../runtime.types"; import { testGuards, type RenderGuard } from "../conditions/guards"; -import { isNonArrayObject } from "../objects"; import { cleanupEffect, ContainerContext, @@ -265,10 +264,7 @@ const adaptImageProps = weakFamily( // The computed style is a plain object, so it needs no flattening return { ...rest, - style: { - ...(isNonArrayObject(style) ? style : undefined), - objectFit: contentFit, - }, + style: Object.assign({}, style, { objectFit: contentFit }), }; }, ); From aadb78c7d68294ea3d50dacf752017896e077357 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:12:37 +0300 Subject: [PATCH 07/13] refactor(native): model a target:false collection as a rule target, and turn targets into paths once --- src/compiler/compiler.types.ts | 9 ++++++--- src/native/react/collection-target.ts | 22 +++++++++++++++++++--- src/native/react/rules.ts | 1 - src/native/styles/calculate-props.ts | 5 +++-- src/native/styles/index.ts | 9 +++------ 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 7757dab2..d4401b6e 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -73,9 +73,7 @@ export interface StyleRule { dv?: number; // Target override - target?: string | string[]; - // The props a declaration names land under the target too, as a target:false config collects them - propsUnderTarget?: true; + target?: string | string[] | CollectedTarget; /** * Conditionals @@ -98,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/react/collection-target.ts b/src/native/react/collection-target.ts index f42eac68..2c94fdf1 100644 --- a/src/native/react/collection-target.ts +++ b/src/native/react/collection-target.ts @@ -1,6 +1,22 @@ +import type { StyleRule } from "react-native-css/compiler"; + import type { Config } from "./useNativeCss"; -// A target:false config collects its declarations 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): string | string[] { - return config.target === false ? [config.source] : config.target; +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): string[] { + if (typeof target === "string") { + return [target]; + } + + return "collect" in target ? [target.collect] : target; +} + +export function collectsProps(target: RuleTarget): boolean { + return typeof target === "object" && "collect" in target; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index dc4f8be1..0ec2cca5 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -275,7 +275,6 @@ const getRuleVariation = weakFamily((rule: StyleRule) => { (config: Config): StyleRule => ({ ...rule, target: collectionTarget(config), - ...(config.target === false && { propsUnderTarget: true }), }), ); }); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 88be2f17..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, @@ -60,7 +61,7 @@ export function calculateProps( if (typeof ruleTarget === "string") { target = target[ruleTarget] ??= {}; } else { - for (const path of ruleTarget) { + for (const path of targetPath(ruleTarget)) { target = target[path] ??= {}; } } @@ -74,7 +75,7 @@ export function calculateProps( transformStyles, guards, target, - rule.propsUnderTarget ? target : topLevelTarget, + collectsProps(ruleTarget) ? target : topLevelTarget, ); } } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 391e293e..0105978a 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -8,7 +8,7 @@ import { withDeepPath, withoutKeys, } from "../objects"; -import { collectionTarget } from "../react/collection-target"; +import { collectionTarget, targetPath } from "../react/collection-target"; import { getInteractionHandler } from "../react/interaction"; import type { ComponentState, Config } from "../react/useNativeCss"; import { @@ -224,9 +224,7 @@ export function getStyledProps( } 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]; @@ -578,8 +576,7 @@ function nativeStyleMapping( return props; } - const collection = collectionTarget(config); - const sourcePath = typeof collection === "string" ? [collection] : collection; + const sourcePath = targetPath(collectionTarget(config)); const source: unknown = getDeepPath(props, sourcePath); if (!isNonArrayObject(source)) { From 726857443b7a88f31fe1614fb281aadaeca3e925 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:12:37 +0300 Subject: [PATCH 08/13] perf(native): memoize only the Image props the adapter changes, and the mapping configs per configs --- src/native/react/useNativeCss.ts | 18 +++++++++--------- src/native/styles/index.ts | 27 ++++++++++++++------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index 92e805a4..71b512e9 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -257,14 +257,14 @@ export function mappingToConfig(mapping: StyledConfiguration) { // 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. -const adaptImageProps = weakFamily( - (props: Readonly>) => { - if (!("contentFit" in props)) return props; - const { contentFit, style, ...rest } = props; +const fittedImagePropsFamily = weakFamily( + ({ contentFit, style, ...rest }: Readonly>) => ({ + ...rest, // The computed style is a plain object, so it needs no flattening - return { - ...rest, - style: Object.assign({}, style, { objectFit: contentFit }), - }; - }, + style: Object.assign({}, style, { objectFit: contentFit }), + }), ); + +function adaptImageProps(props: Readonly>) { + return "contentFit" in props ? fittedImagePropsFamily(props) : props; +} diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 0105978a..4ffe3e46 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -548,21 +548,22 @@ function mapEveryConfig( return props; } - const adapted = adaptProps(props); - return configs.some(hasNativeStyleMapping) - ? mappedPropsFamily(configs)(adapted) - : adapted; + return mappedPropsFamily(configs)(adaptProps(props)); } -// Mapping builds new objects, so it runs once per configs and computed props, and a render that changes neither hands React the same objects -const mappedPropsFamily = weakFamily((configs: readonly Config[]) => - weakFamily((props: Readonly>) => - configs.reduce( - (mapped, config) => nativeStyleMapping(config, mapped), - 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); + + return mappingConfigs.length === 0 + ? (props: Readonly>) => props + : weakFamily((props: Readonly>) => + mappingConfigs.reduce( + (mapped, config) => nativeStyleMapping(config, mapped), + props, + ), + ); +}); function hasNativeStyleMapping(config: Config) { return config.nativeStyleMapping !== undefined; From ba06957729005d06db2f9d09e33bbdef6d0592d0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:12:37 +0300 Subject: [PATCH 09/13] test(native): pin inline values beside a target:false mapping into their prop --- src/__tests__/native/components.test.tsx | 43 ++++++++++++++++++++++++ src/__tests__/native/objects.test.ts | 10 ++++++ 2 files changed, 53 insertions(+) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 80effc8a..4ca5e1a0 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; import { + Animated, Button as RNButton, Image as RNImage, KeyboardAvoidingView as RNKeyboardAvoidingView, @@ -389,6 +390,48 @@ test("a target:false mapping into style keeps its value where no inline style me }); }); +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) => { + registerCSS(`.label { color: blue; }`); + let received: Readonly> = {}; + const Received = (props: Readonly>) => { + received = props; + return null; + }; + const Mapped = styled(Received, { + labelClassName: { + target: false, + nativeStyleMapping: { color: destination }, + }, + }); + + render(); + + expect(received[prop]).toBe(inlineValue); + }, +); + 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; diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts index 6bc7b06e..6f063ba1 100644 --- a/src/__tests__/native/objects.test.ts +++ b/src/__tests__/native/objects.test.ts @@ -21,6 +21,16 @@ describe("withDeepPath", () => { }); }); + 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" } }; From f4042535667c0687a37cda0ad3a452671b7f5195 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:17:57 +0300 Subject: [PATCH 10/13] refactor(native): type the configs a mapping runs on, so the mapping checks for none --- src/native/styles/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 4ffe3e46..19194870 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -565,18 +565,16 @@ const mappedPropsFamily = weakFamily((configs: readonly Config[]) => { ); }); -function hasNativeStyleMapping(config: Config) { +type MappingConfig = Config & Required>; + +function hasNativeStyleMapping(config: Config): config is MappingConfig { return config.nativeStyleMapping !== undefined; } function nativeStyleMapping( - config: Config, + config: MappingConfig, props: Readonly>, ) { - if (!config.nativeStyleMapping) { - return props; - } - const sourcePath = targetPath(collectionTarget(config)); const source: unknown = getDeepPath(props, sourcePath); From a5fed08a54d5cc348115d6b7def275c450d79d03 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:06:33 +0300 Subject: [PATCH 11/13] fix(native): keep an inline value beside an !important mapping, and map from the computed props --- src/__tests__/native/components.test.tsx | 127 +++++++++++++++++++++-- src/__tests__/native/objects.test.ts | 24 ++++- src/native/objects.ts | 26 +++-- src/native/react/collection-target.ts | 8 +- src/native/styles/index.ts | 100 ++++++++++++++---- 5 files changed, 237 insertions(+), 48 deletions(-) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 4ca5e1a0..676c5f83 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -413,25 +413,134 @@ test.each([ ] as const)( "%s reaches the component as it came beside a target:false mapping into it", (_, prop, destination, inlineValue) => { - registerCSS(`.label { color: blue; }`); + 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, { - labelClassName: { - target: false, - nativeStyleMapping: { color: destination }, - }, - }); + const Mapped = styled(Received, mapping); - render(); + render(); - expect(received[prop]).toBe(inlineValue); + expect(received).toStrictEqual(expected); }, ); +test("a mapping reads the props as computed, so the configs' order changes nothing", () => { + 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; diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts index 6f063ba1..8d4a04fd 100644 --- a/src/__tests__/native/objects.test.ts +++ b/src/__tests__/native/objects.test.ts @@ -1,4 +1,4 @@ -import { withDeepPath, withoutKeys } from "../../native/objects"; +import { valueAtPath, withDeepPath, withoutKeys } from "../../native/objects"; describe("withDeepPath", () => { test("sets a nested value on copies of the objects on its path, leaving the source untouched", () => { @@ -73,3 +73,25 @@ describe("withoutKeys", () => { 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); + }); +}); diff --git a/src/native/objects.ts b/src/native/objects.ts index 07bc6101..810108c6 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; diff --git a/src/native/react/collection-target.ts b/src/native/react/collection-target.ts index 2c94fdf1..e416f12a 100644 --- a/src/native/react/collection-target.ts +++ b/src/native/react/collection-target.ts @@ -1,4 +1,4 @@ -import type { StyleRule } from "react-native-css/compiler"; +import type { CollectedTarget, StyleRule } from "react-native-css/compiler"; import type { Config } from "./useNativeCss"; @@ -9,14 +9,14 @@ export function collectionTarget(config: Config): RuleTarget { return config.target === false ? { collect: config.source } : config.target; } -export function targetPath(target: RuleTarget): string[] { +export function targetPath(target: RuleTarget): readonly string[] { if (typeof target === "string") { return [target]; } - return "collect" in target ? [target.collect] : target; + return collectsProps(target) ? [target.collect] : target; } -export function collectsProps(target: RuleTarget): boolean { +export function collectsProps(target: RuleTarget): target is CollectedTarget { return typeof target === "object" && "collect" in target; } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 19194870..7fd947e1 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -3,8 +3,8 @@ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; import { specificityCompareFn } from "react-native-css/utilities"; import { - getDeepPath, isNonArrayObject, + valueAtPath, withDeepPath, withoutKeys, } from "../objects"; @@ -205,7 +205,7 @@ 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 @@ -220,7 +220,9 @@ export function getStyledProps( result = deepMergeConfig(config, normal, inline, true); if (important) { - result = deepMergeConfig(config, result, important); + result = config.target + ? deepMergeConfig(config, result, important) + : mergeUnownedProps(result, important, inline); } if (result && config.target) { @@ -558,40 +560,92 @@ const mappedPropsFamily = weakFamily((configs: readonly Config[]) => { return mappingConfigs.length === 0 ? (props: Readonly>) => props : weakFamily((props: Readonly>) => - mappingConfigs.reduce( - (mapped, config) => nativeStyleMapping(config, mapped), - props, - ), + nativeStyleMapping(mappingConfigs, props), ); }); -type MappingConfig = Config & Required>; +type MappingConfig = Readonly & { + readonly nativeStyleMapping: Readonly< + NonNullable + >; +}; -function hasNativeStyleMapping(config: Config): config is MappingConfig { +function hasNativeStyleMapping( + config: Readonly, +): config is MappingConfig { return config.nativeStyleMapping !== undefined; } +// 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 and the configs' order changes nothing function nativeStyleMapping( - config: MappingConfig, + configs: readonly MappingConfig[], props: Readonly>, ) { - const sourcePath = targetPath(collectionTarget(config)); - const source: unknown = getDeepPath(props, sourcePath); - - if (!isNonArrayObject(source)) { - return props; - } + 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 }) => { + const current = valueAtPath(result, sourcePath); + if (!isNonArrayObject(current)) { + return result; + } + const remaining = withoutKeys( + current, + Object.keys(config.nativeStyleMapping), + ); + return remaining === current + ? result + : withDeepPath(result, sourcePath, remaining); + }, 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, + ); +} - const remaining = withoutKeys(source, Object.keys(config.nativeStyleMapping)); - let result = - remaining === source ? props : withDeepPath(props, sourcePath, remaining); +// 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 }; - for (const [key, path] of Object.entries(config.nativeStyleMapping)) { - const value = source[key]; - if (value !== undefined) { - result = withDeepPath(result, path.split("."), value); + for (const [key, value] of Object.entries(important)) { + if (value === undefined || suppliesProp(inline, key)) { + continue; } + const current = result[key]; + result[key] = + isNonArrayObject(current) && isNonArrayObject(value) + ? { ...current, ...value } + : value; } 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) + ); +} From b683a48d245347b1b3b0b5a2159bfc03fc610f3f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:10:01 +0300 Subject: [PATCH 12/13] refactor(native): strip mapped keys through withoutKeysAt, which keeps a source that loses none --- src/__tests__/native/objects.test.ts | 30 +++++++++++++++++++++++++++- src/native/objects.ts | 19 ++++++++++++++++++ src/native/styles/index.ts | 20 ++++++------------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts index 8d4a04fd..666c259d 100644 --- a/src/__tests__/native/objects.test.ts +++ b/src/__tests__/native/objects.test.ts @@ -1,4 +1,9 @@ -import { valueAtPath, withDeepPath, withoutKeys } from "../../native/objects"; +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", () => { @@ -95,3 +100,26 @@ describe("valueAtPath", () => { 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/native/objects.ts b/src/native/objects.ts index 810108c6..2f9ca033 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -148,3 +148,22 @@ export function withoutKeys( 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/styles/index.ts b/src/native/styles/index.ts index 7fd947e1..13d11af2 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -6,7 +6,7 @@ import { isNonArrayObject, valueAtPath, withDeepPath, - withoutKeys, + withoutKeysAt, } from "../objects"; import { collectionTarget, targetPath } from "../react/collection-target"; import { getInteractionHandler } from "../react/interaction"; @@ -587,19 +587,11 @@ function nativeStyleMapping( return isNonArrayObject(source) ? [{ config, sourcePath, source }] : []; }); - const stripped = sources.reduce((result, { config, sourcePath }) => { - const current = valueAtPath(result, sourcePath); - if (!isNonArrayObject(current)) { - return result; - } - const remaining = withoutKeys( - current, - Object.keys(config.nativeStyleMapping), - ); - return remaining === current - ? result - : withDeepPath(result, sourcePath, remaining); - }, props); + const stripped = sources.reduce( + (result, { config, sourcePath }) => + withoutKeysAt(result, sourcePath, Object.keys(config.nativeStyleMapping)), + props, + ); return sources.reduce( (result, { config, source }) => From 9b815126613654b92143117614a13ffd3d14f6ec Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:41:18 +0300 Subject: [PATCH 13/13] test(native): name the mapping order case for what it holds --- src/__tests__/native/components.test.tsx | 2 +- src/native/styles/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 676c5f83..729458aa 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -507,7 +507,7 @@ test.each([ }, ); -test("a mapping reads the props as computed, so the configs' order changes nothing", () => { +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, diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 13d11af2..7cf9b4d6 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -576,7 +576,7 @@ function hasNativeStyleMapping( return config.nativeStyleMapping !== undefined; } -// 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 and the configs' order changes nothing +// 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>,