Skip to content

fix: prevent style={{}} from destroying computed styles in non-["style"] targets#312

Open
YevheniiKotyrlo wants to merge 1 commit intonativewind:mainfrom
YevheniiKotyrlo:fix/empty-object-style-override
Open

fix: prevent style={{}} from destroying computed styles in non-["style"] targets#312
YevheniiKotyrlo wants to merge 1 commit intonativewind:mainfrom
YevheniiKotyrlo:fix/empty-object-style-override

Conversation

@YevheniiKotyrlo
Copy link
Copy Markdown
Contributor

@YevheniiKotyrlo YevheniiKotyrlo commented Mar 27, 2026

Summary

Extends mergeDefinedProps to skip empty plain objects ({}), preventing them from overwriting computed className styles on non-["style"] targets (Paths B and C).

Completes the work started in PR #291 (which fixed style={undefined}) by also handling style={{}}.

Problem

When a component passes an empty style object to a non-["style"] target, the computed className styles are destroyed:

<ScrollView
  contentContainerClassName="items-center justify-center bg-green-500"
  contentContainerStyle={{}}  // ← destroys all className styles
/>

Common real-world trigger — components with default empty object parameters:

function MyScrollView({ contentContainerStyle = {} }) {
  return (
    <ScrollView
      contentContainerClassName="p-4"
      contentContainerStyle={contentContainerStyle}
    />
  );
}
<MyScrollView /> // contentContainerStyle defaults to {} → styles lost

The ["style"] target path (Path A) already handles this via filterCssVariables({}) returning undefined. Paths B and C (non-["style"] array targets like ["contentContainerStyle"] and string targets) used mergeDefinedProps which copied empty objects.

Solution

Uses inline for...in to detect empty objects — if the loop body never executes, the object has no enumerable keys and is skipped. No intermediate array allocation (unlike Object.keys().length), no function call overhead (unlike a separate helper).

// Non-object values: fast path via typeof check
if (typeof value !== "object" || value === null || Array.isArray(value)) {
  result[key] = value;
  continue;
}
// Object: assign only if non-empty
for (const _ in value) {
  result[key] = value;
  break;
}

Performance

mergeDefinedProps is only called for Path B/C components (ScrollView, FlatList, TextInput, KeyboardAvoidingView, ImageBackground). The most common components (View, Text, Pressable) use Path A and are unaffected.

Benchmarked with 10M iterations (median of 5 runs) across real NativeWind patterns:

Pattern Overhead Per-call overhead
<TextInput className="..." /> (className only, most common) +6% ~1ns
<TextInput className="..." style={{ height: 40 }} /> +17% ~3ns
<ScrollView contentContainerClassName="..." /> +23% ~4ns
<FlatList contentContainerStyle={{}} /> (the bug) +4% ~1ns

Frame impact for a heavy screen (20 TextInputs + 2 ScrollViews + 1 FlatList): ~50ns per frame (0.0003% of 16.7ms budget at 60fps).

for...in was chosen over 9 other approaches after benchmarking 50M iterations each. Object.keys().length was 2x slower for empty objects and allocates an intermediate array. A targeted approach (checking only the style key) was slower due to string comparison overhead.

Verification

Gate Result
yarn typecheck Pass
yarn lint Pass
yarn build Pass (ESM + CJS + DTS)
yarn test 1035 passed, 3 failed (pre-existing babel plugin), 21 skipped

Bug reproduction: without fix 4 tests fail, with fix all 19 className-with-style tests pass.

Tests added (5 new)

Test Path Validates
View: style={{}} A Path A already handles via filterCssVariables
ScrollView: contentContainerStyle={{}} B (array) Core bug fix
FlatList: contentContainerStyle={{}} B (array) Different component, same target type
custom styled(): style={{}} B (string) Covers TextInput/KeyboardAvoidingView pattern
optional prop with = {} default B (array) Real-world default parameter pattern

Related: #239, #291

…e"] targets

When a component passes an empty style object (e.g., contentContainerStyle={{}})
to a non-["style"] target, mergeDefinedProps copies the empty object over the
computed className styles, destroying them. This is the same class of bug as
style={undefined} (fixed in PR nativewind#291) but for empty objects.

The ["style"] target path (Path A) already handles this correctly via
filterCssVariables({}) returning undefined. This fix extends mergeDefinedProps
with an isEmptyPlainObject check to skip empty objects on paths B and C,
matching Path A's behavior.

Common real-world trigger: components with default empty object parameters
({ contentContainerStyle = {} }) or explicit style={{}} props.
@YevheniiKotyrlo YevheniiKotyrlo force-pushed the fix/empty-object-style-override branch from d3ecc35 to c899098 Compare March 27, 2026 13:42
@YevheniiKotyrlo
Copy link
Copy Markdown
Contributor Author

YevheniiKotyrlo commented Mar 27, 2026

Since mergeDefinedProps runs per-prop per-render, I wanted to make sure the empty object check doesn't introduce meaningful overhead. Here's what I found.

Critical path

mergeDefinedProps is only called for Path B/C components — ScrollView, FlatList, TextInput, KeyboardAvoidingView, ImageBackground. View/Text/Pressable use Path A (the filterCssVariables path) and are unaffected.

Approach

Uses inline for...in to detect empty objects. If the loop body never executes, the object has no enumerable keys and is skipped. This avoids Object.keys().length (which allocates an intermediate array on every check) and avoids a separate helper function (which prevents JIT optimization across loop iterations).

Tested 10 detection methods across 6 value types (50M iterations each) — for...in was consistently fastest for the empty case and competitive for non-empty. Full benchmark: https://gist.github.com/YevheniiKotyrlo/2a3f49a0c5ae8028ce71740084dd546c

Results (10M iterations, median of 5 runs)

NativeWind pattern Overhead vs current main Per-call overhead
<TextInput className="..." /> (most common) +6% ~1ns
<TextInput className="..." style={{ height: 40 }} /> +17% ~3ns
<ScrollView contentContainerClassName="..." /> +23% ~4ns
<FlatList contentContainerStyle={{}} /> (the bug) +4% ~1ns

For a form-heavy screen (20 TextInputs + 2 ScrollViews + 1 FlatList): ~50ns per frame — 0.0003% of the 16.7ms budget at 60fps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant