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