diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx
index ea9af8e..8896f3b 100644
--- a/src/__tests__/native/className-with-style.test.tsx
+++ b/src/__tests__/native/className-with-style.test.tsx
@@ -175,11 +175,11 @@ describe("style={undefined} should not destroy computed className styles", () =>
/>,
).getByTestId(testID);
- // Non-"style" targets: inline contentContainerStyle overwrites className styles
- // (array coexistence is only implemented for the ["style"] target path)
- expect(component.props.contentContainerStyle).toStrictEqual({
- padding: 10,
- });
+ // Both className and inline contentContainerStyle should coexist as array
+ expect(component.props.contentContainerStyle).toStrictEqual([
+ { backgroundColor: "#008000" },
+ { padding: 10 },
+ ]);
});
test("ScrollView: contentContainerClassName without contentContainerStyle", () => {
@@ -393,3 +393,140 @@ describe("style={{}} should not destroy computed className styles", () => {
});
});
});
+
+/**
+ * Tests for multi-config components (e.g. ScrollView with both className and
+ * contentContainerClassName) where inline style on one target should not
+ * destroy computed className styles on a different target.
+ *
+ * Bug: getStyledProps loops over configs, and each iteration calls deepMergeConfig
+ * which produces a full props object via Object.assign({}, left, right). When a
+ * later config iteration runs, it overwrites the correctly-merged target props
+ * from earlier iterations.
+ *
+ * Example: ScrollView with className="bg-red" and style={{ paddingTop: 10 }}.
+ * The first config (className→style) correctly merges both. The second config
+ * (contentContainerClassName→contentContainerStyle) does Object.assign which
+ * copies the inline style={{ paddingTop: 10 }} over the merged style, destroying
+ * the backgroundColor from className.
+ */
+describe("multi-config: inline style should not destroy className styles on other targets", () => {
+ test("ScrollView: className with style should preserve className styles", () => {
+ registerCSS(`.bg-red { background-color: red; }`);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ // className backgroundColor should coexist with inline paddingTop
+ expect(component.props.style).toStrictEqual([
+ { backgroundColor: "#f00" },
+ { paddingTop: 10 },
+ ]);
+ });
+
+ test("ScrollView: className + contentContainerClassName + style preserves all", () => {
+ registerCSS(`
+ .bg-red { background-color: red; }
+ .p-4 { padding: 16px; }
+ `);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ // className-derived style merged with inline style
+ expect(component.props.style).toStrictEqual([
+ { backgroundColor: "#f00" },
+ { paddingTop: 10 },
+ ]);
+
+ // contentContainerClassName should be independently preserved
+ expect(component.props.contentContainerStyle).toStrictEqual({
+ padding: 16,
+ });
+ });
+
+ test("ScrollView: className + contentContainerClassName + both inline styles", () => {
+ registerCSS(`
+ .bg-red { background-color: red; }
+ .p-4 { padding: 16px; }
+ `);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ // Both targets should have merged className + inline styles
+ expect(component.props.style).toStrictEqual([
+ { backgroundColor: "#f00" },
+ { paddingTop: 10 },
+ ]);
+
+ expect(component.props.contentContainerStyle).toStrictEqual([
+ { padding: 16 },
+ { marginTop: 5 },
+ ]);
+ });
+
+ test("ScrollView: className without style still works (single-config path)", () => {
+ registerCSS(`.bg-red { background-color: red; }`);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({ backgroundColor: "#f00" });
+ });
+
+ test("ScrollView: consumed className sources should be removed from props", () => {
+ registerCSS(`.bg-red { background-color: red; }`);
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ // className and contentContainerClassName should be consumed, not passed through
+ expect(component.props.className).toBeUndefined();
+ expect(component.props.contentContainerClassName).toBeUndefined();
+ });
+
+ test("FlatList: contentContainerClassName with contentContainerStyle preserves both", () => {
+ registerCSS(`.bg-blue { background-color: blue; }`);
+
+ const component = render(
+ null}
+ contentContainerClassName="bg-blue"
+ contentContainerStyle={{ height: 200 }}
+ />,
+ ).getByTestId(testID);
+
+ expect(component.props.contentContainerStyle).toStrictEqual([
+ { backgroundColor: "#00f" },
+ { height: 200 },
+ ]);
+ });
+});
diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts
index 9ac5cc4..c598fc6 100644
--- a/src/native/styles/index.ts
+++ b/src/native/styles/index.ts
@@ -191,6 +191,20 @@ export function getStyledProps(
const styledProps = state.stylesObs?.get(state.styleEffect);
+ // When multiple configs exist (e.g. ScrollView with className→style and
+ // contentContainerClassName→contentContainerStyle), each iteration of
+ // deepMergeConfig produces a full props object via Object.assign({}, left, right).
+ // Later iterations overwrite earlier ones' correctly-merged target props.
+ // We save each iteration's target value and restore them after the loop.
+ //
+ // Note: This uses the leaf key of config.target for storage/restoration.
+ // For nested array targets (length > 1), the leaf key is stored at the
+ // top level, which is correct because deepMergeConfig already builds the
+ // nested structure. If two configs ever share the same leaf key, the last
+ // one wins — but no built-in component mapping produces this scenario.
+ const computedTargets: Record = {};
+ const consumedSources: string[] = [];
+
for (const config of state.configs) {
result = deepMergeConfig(
config,
@@ -207,6 +221,21 @@ export function getStyledProps(
);
}
+ // Save the correctly-merged target prop from this iteration
+ if (result && config.target) {
+ const targetKey = Array.isArray(config.target)
+ ? config.target[config.target.length - 1]
+ : config.target;
+ if (targetKey && targetKey in result) {
+ computedTargets[targetKey] = result[targetKey];
+ }
+ }
+
+ // Track consumed className sources for cleanup
+ if (config.source !== config.target) {
+ consumedSources.push(config.source);
+ }
+
// Apply the handlers
if (hoverFamily.has(state.ruleEffectGetter)) {
result ??= {};
@@ -265,6 +294,17 @@ export function getStyledProps(
}
}
+ // Restore correctly-merged target props that may have been overwritten
+ // by later config iterations' Object.assign({}, left, right)
+ if (result) {
+ for (const key in computedTargets) {
+ result[key] = computedTargets[key];
+ }
+ for (const source of consumedSources) {
+ delete result[source];
+ }
+ }
+
return result;
}
@@ -437,6 +477,49 @@ function deepMergeConfig(
);
}
+ // For length-1 array targets (e.g. ["contentContainerStyle"]), the loop
+ // above runs 0 iterations. Merge the target prop so inline styles don't
+ // silently overwrite className-computed styles (same as string target path).
+ const finalKey = config.target[config.target.length - 1];
+ if (config.target.length === 1 && finalKey && rightIsInline) {
+ let rightValue = right?.[finalKey];
+ if (rightValue !== undefined) {
+ rightValue = filterCssVariables(rightValue);
+ }
+ if (rightValue === undefined || rightValue === null) {
+ // Inline is empty or fully filtered — preserve className-computed value
+ if (left && finalKey in left) {
+ result[finalKey] = left[finalKey];
+ } else {
+ // No left value either — remove unfiltered inline value from result
+ delete result[finalKey];
+ }
+ } 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 (hasNonOverlappingProperties(leftValue, rightValue)) {
+ result[finalKey] = [leftValue, rightValue];
+ } else {
+ // All left keys are in right — use filtered right value
+ result[finalKey] = rightValue;
+ }
+ } else {
+ result[finalKey] = [leftValue, rightValue];
+ }
+ } else {
+ // No left value — use filtered right value (not the unfiltered one from mergeDefinedProps)
+ result[finalKey] = rightValue;
+ }
+ }
+
return result;
}