* nativewind 4 spike * themed and cssInterop fixes * clearify comment on inline Animated.View styles * remaining style fixes and supplementary agent docs * fix pill button on media screens --------- Co-authored-by: Johannes Klein <17345891+jtklein@users.noreply.github.com>
13 KiB
NativeWind v4: What's New and What to Watch For
This app migrated from NativeWind 2 to 4 (see MOB-1148). Most className usage
"just works" unchanged, but v4 resolves classes fundamentally differently than v2,
and that difference produces silent, no-error bugs — wrong size, wrong color, wrong
position, no visible change at all — rather than build failures. This doc explains
the mental model shift and the specific failure patterns found (and fixed) during
the migration, so the same bugs aren't reintroduced in new code.
The core difference from v2
v2 used a Babel transform. <View className="p-4" /> was rewritten at compile
time into a component that resolved the class string to a style object at
render time, by interpreting the string directly against the Tailwind config.
Any class string — including one built at runtime from a variable — worked, and
whichever class came last in the resolved string generally won a conflict.
v4 splits this into two independent halves:
- A build-time CSS pipeline —
global.css's@tailwinddirectives are compiled to real CSS by PostCSS/Tailwind (via static content-scanning of.tsx/.jsx/.ts/.jssource files), then converted into a JS rule table that Metro injects into the bundle. - A runtime interop layer (
react-native-css-interop) that holds that rule table in memory and, when a registered component renders with aclassNameprop, looks up the matching rule and produces a style object.
Because compilation happens by statically scanning literal text in source files, not by evaluating JS at runtime, several v2 patterns that "just worked" now fail silently. Below are the specific patterns to watch for, roughly in order of how often they've bitten us.
1. Only registered components respond to className
Core React Native components (View, Text, TextInput, Pressable,
ScrollView, Image, ImageBackground, Modal, ActivityIndicator,
FlatList, Touchable*, StatusBar, Switch) are registered automatically.
Everything else — any third-party / non-core-RN component — is not, and a
className passed to it is silently dropped. No warning, no error; the prop is
just ignored.
This bit us repeatedly: react-native-webview's WebView, react-native-paper's
TextInput and ActivityIndicator, react-native-maps' MapView,
react-native-draggable-flatlist's DraggableFlatList,
react-native-reanimated-carousel's Carousel, and more — each one rendered
with wrong/default sizing, invisible content, or a broken layout because
className had zero effect.
Fix: register the component once with cssInterop() and export the styled
version so callers import it from one place instead of the raw package.
src/components/styledComponents.ts is the shared registry:
import { cssInterop } from "nativewind";
import { TextInput as UnstyledPaperTextInput } from "react-native-paper";
const PaperTextInput = cssInterop( UnstyledPaperTextInput, { className: "style" } );
export { PaperTextInput /* , ... */ };
Then import PaperTextInput (etc.) from components/styledComponents instead
of the raw package.
Currently registered: SafeAreaView, BottomSheetTextInput,
LinearGradient, FasterImageView, PaperTextInput, PaperActivityIndicator,
WebView, DraggableFlatList, Carousel (all in styledComponents.ts), plus
MapView (registered directly in Map.tsx, since it's only used there).
If you introduce a new third-party visual component and it needs className,
register it here first — don't discover this the hard way in a screenshot.
2. Conflicting classes resolve by stylesheet order, not by string order
In v2, when two classes touching the same CSS property landed on one element, whichever was listed last in the string won. In v4, the winner is whichever rule the Tailwind compiler happened to emit later in the generated stylesheet — which has no reliable relationship to the order you wrote the classes in JS.
This mostly bites components that build up "base classes" + "caller's override"
with plain string concatenation (classnames()):
// BROKEN under v4 — the caller's border-[2px] can lose to the component's own
// border-[3px] regardless of which one is "supposed" to win
buttonClasses.push( "border border-[3px]" ); // component default
if ( className ) buttonClasses.push( className ); // caller's override, e.g. "border-[2px]"
return classnames( buttonClasses );
This exact shape caused a reported bug: Button's own default border width
(border-[3px]) was silently overriding callers that passed border-[2px]
to make a thinner border, because the caller's className was pushed into the
array before the level-specific defaults.
Fix: use tailwind-merge's twMerge() instead of classnames() wherever
defaults and a caller's override are combined, and put the caller's override
last in the argument list (twMerge resolves same-property conflicts with
last-argument-wins, mirroring v2's semantics):
// CORRECT
return twMerge( ...buttonClasses, className );
Already applied in Button.tsx, ViewWrapper.tsx, BottomSheet.tsx,
INatIconButton.tsx, and the Typography/* components. If you're writing a
component that accepts a className prop and has its own default classes,
use twMerge, not classnames(), to combine them.
3. An inline style always beats a className for the same property
This is a plain CSS/RN fact that v2's resolution model happened to route around,
but v4 exposes directly: if a component sets style={{ textAlign: "left" }}
unconditionally and also accepts className, a caller's text-center class
cannot win — the inline style always wins for that property, no matter what
classes are present.
Two real bugs from this:
InatText's hardcodedstyle={{ textAlign: "left" }}beat every caller'stext-center/text-rightclass.INatIconButton'sstyle={{ width: 44, height: 44 }}(its accessible-min-size default) beat every caller trying to size the button viaw-[36px] h-[36px]className instead of thewidth/heightprops.
Fix pattern: don't put a "default" value into style unconditionally if a
caller might reasonably want to override it via className. Prefer expressing
the default as a className merged with twMerge instead of a literal style
value — that puts the default and the override through the same resolution
mechanism instead of two different ones:
// INatIconButton.tsx — default size is a class, not a style, so a caller's
// own w-/h- className can win via twMerge
const DEFAULT_DIM_CLASSES = "w-11 h-11"; // 44px
...
const dimClassName = twMerge( DEFAULT_DIM_CLASSES, className );
width/height only enter style here when the caller explicitly passes them
as numeric props (a deliberate, dynamic-value escape hatch) — see the file for
the full pattern, including the props-validation nuance.
A sharp edge inside this pattern: if you conditionally build the style object,
setting a key to undefined is not the same as omitting the key. Object-merge
semantics (Object.assign/spread) let an explicit undefined value overwrite
a value that was merged in from elsewhere, whereas an absent key does not:
// WRONG — the key is present (with value undefined), and can still clobber a
// className-derived value during nativewind's internal style merge
{ width: someCondition ? undefined : width }
// CORRECT — the key is entirely absent when the condition is true
{ ...( someCondition ? {} : { width } ) }
This exact mistake caused a second-order bug during the INatIconButton fix
above and only showed up as a snapshot-test failure, not a lint/type error —
worth being deliberate about whenever a style object's keys are conditional.
4. A class name built from a template literal never compiles
const BUTTON_DIM = 40;
const classes = `h-[${BUTTON_DIM}px]`; // produces ZERO CSS, silently
Verified by hand-compiling: this generates no rule at all. Tailwind's
compiler finds candidate class names by scanning the raw text of source
files — it doesn't evaluate JS, so it never sees the resolved string
"h-[40px]", only the literal (invalid) text h-[${BUTTON_DIM}px]. In v2
this worked because resolution happened at runtime against the actual
interpolated string.
This was found (and fixed) in MediaNavButtons.tsx and TabletButtons.tsx,
both of which built arbitrary-value size classes this way for camera/sound
recorder buttons — the button in question had no explicit size at all, and
fell back to whatever a sibling flex class happened to produce (in one case, a
tall, narrow green bar instead of a 40px circle).
Fix: write the literal class, don't interpolate a variable into it. If the
value needs to stay linked to a named constant for documentation purposes,
prefer Tailwind's own default scale over an arbitrary-value bracket when the
number allows it (Tailwind's scale is n → n * 0.25rem, so 40px = h-10,
44px = h-11 at this app's 16px rem base):
// WRONG
`h-[${BUTTON_DIM}px]`
// RIGHT — either works; prefer the scale utility when the px value maps cleanly
"h-10" // 2.5rem = 40px
"h-[40px]" // also fine — it's a literal string, not interpolated
If a value is genuinely dynamic at runtime (can't be reduced to a small fixed
set of literal classes), it has to be expressed via style, not className —
nativewind cannot generate a CSS rule for every possible runtime number ahead
of time.
5. space-x-* / space-y-* are gone — use gap-x-* / gap-y-*
NativeWind 2 polyfilled space-x-*/space-y-*; v4 dropped them. They compile
to nothing and are silently ignored — the classic v4 failure mode again. 34
occurrences across 19 files were found and converted to gap-x-*/gap-y-*
during this migration (same visual result). If you see rows/columns with items
jammed together where there should be a gap, check for a stray space-x/space-y.
6. The rem base changed from 16px to 14px
NativeWind 4 defaults to inlining 1rem = 14px; this app's whole spacing scale
was built against v2's 16px assumption. This is already fixed globally —
metro.config.js's withNativeWind() call sets inlineRem: 16, and
tests/nativewind.setup.js passes the same value when converting compiled CSS
for Jest. Both must stay in sync. If you ever see spacing/sizing that's
consistently ~12.5% smaller than expected, check that these two values still
match.
Testing implications
Jest doesn't run Metro, so neither half of nativewind's pipeline (CSS compilation, component registration) happens automatically in tests. This is wired up manually:
tests/jest.globalSetup.jscompilesglobal.csswith real PostCSS + Tailwind once per test run and caches the output.tests/nativewind.setup.js(a JestsetupFilesentry) registers the core components and injects the compiled CSS into the interop runtime, soclassNameresolves to real style values in tests the same way it does on-device — this is what let the bugs above surface as concrete style-value assertions/snapshot diffs instead of being invisible to the test suite.
This relies on undocumented internal paths of react-native-css-interop
(dist/runtime/components, dist/css-to-rn, dist/runtime/native/styles).
A future nativewind/react-native-css-interop version bump could rename or
restructure these without it counting as a breaking change from their public
API's perspective — if classes stop resolving in tests after an upgrade, look
here first.
jest.mock() factories can't use a plain require("react"). The interop's
Babel plugin rewrites React.createElement (when React comes from a plain
require("react") or import) to reference a hoisted import, and
jest.mock() factories aren't allowed to reference out-of-scope variables like
that hoisted import. Use jest.requireActual("react") instead:
// WRONG — throws "module factory is not allowed to reference out-of-scope variables"
jest.mock( "some/Component", () => {
const React = require( "react" );
return jest.fn( ( { children } ) => React.createElement( View, null, children ) );
} );
// CORRECT
jest.mock( "some/Component", () => {
const React = jest.requireActual( "react" );
return jest.fn( ( { children } ) => React.createElement( View, null, children ) );
} );
Quick diagnostic checklist
| Symptom | Likely cause |
|---|---|
className has no visible effect at all |
Component isn't registered — see #1 |
| A caller's override class isn't winning over a component's own default class | Plain classnames() merge instead of twMerge — see #2 |
A numeric prop (width, height, alignment, etc.) should be overridable by className but isn't |
An unconditional inline style value is beating the className — see #3 |
| Conditionally-omitted style value still seems to apply | A style key is set to undefined instead of omitted — see #3 |
An arbitrary-value class (h-[${x}px]) has zero effect |
Template-literal interpolation never compiles — see #4 |
| Missing gap between flex row/column items | Stray space-x-*/space-y-* — see #5 |
| Spacing/sizing is uniformly ~12.5% smaller than expected | rem-base mismatch between metro.config.js and tests/nativewind.setup.js — see #6 |
| A test asserting on resolved styles fails after a nativewind/css-interop bump | Internal API paths in tests/nativewind.setup.js may have moved — see Testing implications |