Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 142 additions & 5 deletions src/__tests__/native/className-with-style.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,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", () => {
Expand Down Expand Up @@ -257,3 +257,140 @@ describe("style={undefined} 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(
<ScrollView
testID={testID}
className="bg-red"
style={{ paddingTop: 10 }}
/>,
).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(
<ScrollView
testID={testID}
className="bg-red"
contentContainerClassName="p-4"
style={{ paddingTop: 10 }}
/>,
).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(
<ScrollView
testID={testID}
className="bg-red"
contentContainerClassName="p-4"
style={{ paddingTop: 10 }}
contentContainerStyle={{ marginTop: 5 }}
/>,
).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(
<ScrollView testID={testID} className="bg-red" />,
).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(
<ScrollView
testID={testID}
className="bg-red"
contentContainerClassName="bg-red"
style={{ paddingTop: 10 }}
/>,
).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(
<FlatList
testID={testID}
data={[]}
renderItem={() => null}
contentContainerClassName="bg-blue"
contentContainerStyle={{ height: 200 }}
/>,
).getByTestId(testID);

expect(component.props.contentContainerStyle).toStrictEqual([
{ backgroundColor: "#00f" },
{ height: 200 },
]);
});
});
83 changes: 83 additions & 0 deletions src/native/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
const consumedSources: string[] = [];

for (const config of state.configs) {
result = deepMergeConfig(
config,
Expand All @@ -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 ??= {};
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -418,6 +458,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;
}

Expand Down
Loading