Skip to content

feat(native): interop react-native-gesture-handler's Pressable and button family - #416

Open
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/gesture-handler-interop
Open

feat(native): interop react-native-gesture-handler's Pressable and button family#416
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/gesture-handler-interop

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Problem

react-native-gesture-handler's Pressable and button family render GestureHandlerButton, a codegen'd native component. The react-native import rewrite never reaches them, so className falls through their prop spread onto a view that declares no such prop — it reaches no pixel, and nothing warns. Measured on device: a gesture-handler Pressable carrying flex flex-row items-center gap-2 rounded-md px-3 py-2 rendered 22×114 px against a plain View control's 132×101, stacked vertically because flex-row never applied.

The type side has the same hole on the buttons. RawButtonProps extends NativeViewGestureHandlerProps and AccessibilityProps, never ViewProps, so BaseButtonProps, RectButtonProps and BorderlessButtonProps never inherit the className this library adds. (RNGH's own Pressable does reach it — its props are Omit<ViewProps, 'children' | 'style' | 'hitSlop'>, which keeps className.)

Fix

A styled react-native-gesture-handler entry, resolved the way the existing react-native-safe-area-context one is, mapping className onto the style each component already forwards. Plus a RawButtonProps augmentation in types.d.ts for the button family.

Seven components are re-declared: Pressable, RawButton, BaseButton, RectButton, BorderlessButton, PureNativeButton and DrawerLayoutAndroid.

Which components are not re-declared, and why, is the part worth reviewing. Every line of it is now a test rather than a comment:

  • ScrollView, Switch, TextInput, FlatList, TextcreateNativeWrapper forwards unclaimed props to a react-native primitive, and Text renders one directly, so the react-native rewrite already reaches these. Wrapping them would style the gesture handler rather than the view.
  • The four touchables, DrawerLayout, SwipeableclassName is dropped on all six. Gesture Handler marks every one @deprecated in favour of Pressable and the Reanimated twins, and this repo lints @typescript-eslint/no-deprecated at error, so wrapping them would need a suppression in your own source for a surface scheduled for deletion.
  • RefreshControlclassName is dropped. React Native's jest mock renders <RCTRefreshControl /> with no props at all, so no test at this tier could observe a fix, and style on a RefreshControl drives nothing on either platform.
  • ReanimatedDrawerLayout, ReanimatedSwipeable — out of reach: they ship as their own entry points rather than from the index, and the resolver branch matches an exact react-native-gesture-handler.

Two things a later pass found, and what now stops them recurring

PureNativeButton is a sixth member of the button family — the same codegen'd RNGestureHandlerButton, exported directly — and the first version of this PR neither re-declared nor excluded it. It rendered {"type":"RNGestureHandlerButton","props":{"className":"pnb"}}: the raw class string on a native view, which is worse than dropping it.

Two exclusions did not do what they claimed. TouchableNativeFeedback was excluded on the grounds that it "re-exports React Native's, which the rewrite already reaches". The rewrite does reach the specifier — and components/index.cts hands back require("react-native").TouchableNativeFeedback untouched, with no styled twin, so the class is dropped on every platform rather than only on Android. DrawerLayoutAndroid was excluded as re-exported straight from react-native; gesture-handler wraps it in createNativeWrapper instead, so the styled twin that exclusion assumed does not exist in either direction, and the class was simply dropped. It is now re-declared.

Both are structural, not clerical, so the guards are too:

  • The census is derived from the module. A member counts as re-declared iff its export is no longer the one export * provided, and the excluded set is every remaining export that renders. The two are computed, not listed, so an eighth component landing upstream joins the generated cases on the commit that introduces it.
  • Every excluded member is held to a drop, not a leak. Removing the PureNativeButton re-declaration now fails on className surviving onto the rendered element, which is the defect itself — not on a name being absent from a list.
  • An accounting test still requires every export to name its reason, so a member can be neither re-declared nor excluded only by turning the suite red.
  • TouchableNativeFeedback and DrawerLayoutAndroid are shown resolving to react-native's own object under a real rewrite, beside View, Text, ScrollView, TouchableOpacity and TouchableHighlight, which resolve to a styled twin. "The rewrite reaches it" and "the class survives" are separated by object identity rather than by prose.

A core defect this also fixes: a callback style prop stopped being a callback

deepMergeConfig in src/native/styles/index.ts merged a style value as data. A function fails both object tests, so it fell into the array branch and produced style: [computed, theCallbackItself] — an array containing the un-invoked function. Pressable picks its branch with typeof style === "function"; wrapped in an array the answer is "object", so the callback never ran and the raw function reached the native view. Every pressed-state style silently gone, no warning.

Measured on <Pressable className="text-red" style={({pressed}) => …} />: [{color:"#f00"}, [Function style]] before, [{color:"#f00"}, {opacity:1}] after.

No gesture-handler code is on that path. src/components/Pressable.tsx carries the identical { className: "style" } mapping and is the twin the react-native rewrite installs across the whole graph, so every <Pressable className=… style={({pressed}) => …}> in any app was affected, as was any user styled() mapping whose target prop holds a callback. Its regression tests import react-native-css/components/Pressable and no gesture-handler at all. Happy to split it into its own PR if you would rather — say the word and I will.

style merges at four sites inside deepMergeConfig and the guard is on all four. Two notes from measuring them:

  • The string-target branch never enters the array handling, so nothing the ["style"] cases assert reaches it. It has its own test; reverting that one guard reddens it alone.
  • The inline ["style"] guard executes but its result is discarded: the length-1 array block a few lines down recomputes the same key from the same operands. Reverting it reddens nothing, and no test can make it. I left it as the symmetric form — say if you would rather it went.

Tests

Both fixes are tested on both planes.

Compiler plane. src/__tests__/compiler/react-native-gesture-handler.test.tsx says what that plane carries for the interop, which is nothing — and measures it rather than asserting it. One declaration is asserted byte-for-byte, then driven into a react-native primitive and into every re-declared gesture-handler member, with each unwrapped twin beside it leaving the same bytes unresolved. Pointing RectButton's mapping at a prop that is not style reddens five render cases and leaves both compile assertions green; that insensitivity is the proof the fix does not belong on this plane. It also contrasts the emitted declaration against @nativeMapping's, so a reader looking for a compiler feature behind the interop can see there is none.

src/__tests__/compiler/important.test.tsx is the second fix's compiler half, and !important had no compiler coverage in the repo at all. The marker lands in the rule's specificity array at Specificity.Important — read through the exported census rather than the literal index — and it is what selects which of deepMergeConfig's two passes a class takes, visible as the operand order inverting between them. A callback has to survive whichever pass the marker selects, so the two are asserted together.

Native plane. Per re-declared component: the fix (className resolves into the rendered style), the invariant (className never reaches a rendered element as a prop), the trigger (the unwrapped component drops it, so the bug is pinned and cannot silently stop being a bug), coexistence with an inline style, and byte-identical output when no className is given — that last one because the resolver routes every gesture-handler import in the graph through this wrapper, react-navigation and react-native-screens included. Each registers a width no other test uses.

react-native-gesture-handler-rewrite.test.tsx renders the five rewrite-dependent members under a real react-nativereact-native-css/components mock. Its sibling shows the same five leaking the raw class string without that mock, which is what makes the rewrite load-bearing rather than a comment.

Every generated census carries a non-empty assertion, so a narrowed export surface cannot quietly produce zero cases.

Gates

Measured at the head of this branch, on Windows:

Gate Result
yarn typecheck clean
yarn lint clean
yarn build module + commonjs + typescript
yarn test Test Suites: 2 failed, 4 skipped, 58 passed, 60 of 64 total / Tests: 3 failed, 21 skipped, 1170 passed, 1194 total

The 3 failures are Windows-only and pre-existingreact-native › plugin › 7, react-native-web › plugin › 6 and react-native-web › plugin › 17, path-rewrite cases expecting require("react-native-css/components/View") and getting require("../View"). They are red on pristine main on this machine and are the exact shape #390 addresses. Your CI is green on them, so please read "3 pre-existing failures" as machine-local rather than as a claim about the repo. Run twice before reading any total on Windows — a cold cache can drop a whole suite, which subtracts its count with zero failures to show for it.

react-native-gesture-handler@2.28.0 is added as an exact-pinned devDependency — the version Expo SDK 54 bundles, mirroring how react-native-safe-area-context: 5.6.1 is pinned against that SDK's ~5.6.0.

Open questions

  1. The merge-engine fix arguably belongs in its own PR. See above; splitting is a one-command job if you prefer it.
  2. src/metro/resolver.ts changes resolution beyond gesture-handler, twice. The new branch rewrites every exact react-native-gesture-handler import in the graph — defended by the byte-identical-output test, and narrow enough that subpath imports are untouched. Separately, the own-package exemption is rewritten from a fixed ../../../ depth to a layout-anchored package root: under the react-native export condition __dirname is <pkg>/src/metro, and the old derivation resolved to node_modules/src, so the exemption missed and this package's own modules were fed back through the wrappers that import them. That is a real fix on a path shared by the react-native, safe-area and react-native/Libraries/* rewrites.
  3. The touchables, DrawerLayout and Swipeable are excluded as @deprecated. If you would rather they were styled, the mapping is the same { className: "style" } and the exclusion is one register entry to move — though DrawerLayout and Swipeable would still need a decision about which of containerStyle / childrenContainerStyle / drawerContainerStyle className targets, and whether the others get their own *ClassName props the way ScrollView has contentContainerClassName.
  4. Augmenting an optional peer in types.d.ts. react-native-gesture-handler is a devDependency here, not a peer, and there is no peerDependenciesMeta block — mirroring how react-native-safe-area-context is carried. So the resolver unconditionally rewrites a package that is neither declared nor optional, and types.d.ts augments a module a consumer may not have installed. Measured not to break such a consumer (a declare module naming an absent package compiles clean under skipLibCheck: false), but it is a new pattern and you may prefer an opt-in ./types/* entry.
  5. src/__tests__/metro/ is a new test directory for a module that had none, and src/__tests__/_gesture-handler.ts is a shared helper on the _-prefix convention vendor/tailwind/_tailwind.tsx already uses.

Interaction with #418. This PR adds a react-native-gesture-handler module augmentation to types.d.ts; #418 rewrites that same file to declare every prop | undefined for exactOptionalPropertyTypes. The two overlap textually and whichever lands second will need a trivial rebase. The new RawButtonProps block here already declares both of its props | undefined, so it is consistent with #418's rule either way — but if #416 lands first, #418's sweep should be re-run so the file has no exception, and if #418 lands first, nothing here changes.

Gesture Handler renders `GestureHandlerButton`, a codegen'd native component,
so the Metro resolver's `react-native` rewrite never reaches it and `className`
falls through the prop spread onto a view that declares no such prop. The
class string is computed correctly and reaches no pixel.

Adds a `components/react-native-gesture-handler` wrapper on the same shape as
the existing `react-native-safe-area-context` one: `export *` plus a
`useCssElement` re-declaration per affected component, a `nativeResolver`
branch, and the exports entry whose `react-native` condition points at the
native source.

`RawButtonProps` gains `className` in types.d.ts. Every other styled component
here reaches it through React Native's own props, but the button family extends
neither `ViewProps` nor `TouchableWithoutFeedbackProps`.
The exclusion register stays — which components are deliberately not re-declared,
and why, is the part a reader would otherwise undo — but as a terse list rather
than four paragraphs of argument.
`Pressable` declares `style` as either styles or `(state) => styles` and picks
between them with `typeof style === "function"`. The className merge turned that
into an array, so the check answered "object", the callback never ran, and the
raw function reached the view — every pressed-state style silently dropped.

Measured on `<Pressable className="text-red" style={({pressed}) => …} />`:
`[{color:"#f00"}, [Function style]]` before, `[{color:"#f00"}, {opacity:1}]` after.

`style` merges at four sites inside `deepMergeConfig` — the inline pass and the
important pass of the length-1 `["style"]` branch, the length-1 array-target
block that overwrites the first of those, and the string-target path — so the
guard goes on all four rather than on the one the first reproduction hit.
`nativeResolver` takes its resolver as its first argument, so a recording
`CustomResolver` drives every branch with no Metro: the three module rewrites,
the `react-native/Libraries/*` lookup against `allowedModules`, and each of the
four cases that must pass through untouched. `src/__tests__/metro/` is the first
test directory for this module.

That coverage turned up `isFromThisModule` resolving one level too high whenever
the `source` export condition wins, because it counted a fixed number of levels
up from `__dirname` and the built layout is a directory deeper than the source
one. A missed exemption is a resolution cycle — this package's own components
sent back through the wrapper that imports them — so the anchor is now the
segment that names the layout.
`PureNativeButton` is a sixth member of the button family — the same codegen'd
`RNGestureHandlerButton`, exported directly — and was neither re-declared nor
excluded. Measured before: `{"type":"RNGestureHandlerButton","props":{"className":"pnb"}}`.
`DrawerLayoutAndroid` was excluded on the grounds that `components/index.cts`
re-exports it from react-native, but gesture-handler wraps it in
`createNativeWrapper` rather than re-exporting it, so the styled twin the
exclusion assumed does not exist and the class was dropped. Both are re-declared;
both forward `style`.

Two register entries were wrong about mechanism. The touchables were said to be
reached by the rewrite on non-Android platforms; they are not — `index.cts` has
no styled `TouchableNativeFeedback`, so the rewrite hands back react-native's own.
`DrawerLayout` and `Swipeable` were called a design decision; both are
`@deprecated`, which is the same ground the touchables stand on and is checkable.
`RefreshControl` now says plainly that the class is dropped.

The census the cases are generated from is derived from the module — a member is
re-declared iff its export is no longer the one `export *` provided — so a
seventh is covered the moment it lands, and an accounting test requires every
remaining export to name its reason. That pairing is what makes the omission
this commit fixes impossible to repeat.

Test C asserted an absence over an empty set: raw `RawButton` renders no `style`
prop at all, so it passed with a misspelled class or a component rendering
nothing. It now pins that the same declaration reaches the re-declared twin
first. `flattenStyles` flattened one array level and so could not see the nested
shape the Pressable merge produces.

`react-native-gesture-handler-rewrite.test.tsx` renders the five excluded
className-aware re-exports under the `react-native` rewrite, which is what the
exclusion actually claims; the object-identity assertions beside it are what
`export *` guarantees by construction.
Neither fix on this branch had a compiler-plane test, and for the interop the
honest statement there is that the plane carries nothing: `compile` takes a CSS
string and no component, so no artifact it emits can know who consumes it.
`compiler/react-native-gesture-handler.test.tsx` measures that rather than
asserting it — one compiled declaration is asserted byte-for-byte, then driven
into a react-native primitive and every re-declared gesture-handler member, with
each unwrapped twin beside it leaving the same bytes unresolved. Pointing
RectButton's mapping at a prop that is not `style` reddens five render cases and
leaves both compile assertions green; that insensitivity is the proof.

`compiler/important.test.tsx` is the other fix's compiler half, and `!important`
had no compiler coverage at all. The marker lands in the rule's specificity array
at `Specificity.Important`, read through the exported census rather than the
literal index, and it is what selects which of `deepMergeConfig`'s two passes a
class takes — visible as the operand order inverting between them. A callback
`style` has to survive whichever pass the marker selects, so the two are tested
together.

The exclusion register's claims are now measurements. The five members said to be
reached by the `react-native` rewrite are shown LEAKING the raw class string
without it, which is what makes the rewrite load-bearing rather than a comment.
`TouchableNativeFeedback` and `DrawerLayoutAndroid` are shown resolving to
react-native's own object under that rewrite, beside five names that resolve to a
styled twin — so "the rewrite reaches it" and "the class survives" are separated
by object identity. Gesture Handler's `DrawerLayoutAndroid` is pinned as its own
`createNativeWrapper` component rather than a re-export.

The census the drop invariant is generated from is derived from the export
surface — every member that renders and is not re-declared — rather than from the
union of the reason buckets. The two differ exactly when a member has been
missed, so an unhandled component is rendered and held to the invariant on the
commit that introduces it: removing the `PureNativeButton` re-declaration now
fails on the leak itself, not only on the accounting.

`deepMergeConfig` guards a callback at four sites and two of them were unobserved.
The string-target branch never enters the array handling, so nothing the
`["style"]` cases assert reaches it; it now has a test, and reverting that guard
reddens it alone. Reverting the inline `["style"]` guard still reddens nothing:
the length-1 array block recomputes the same key from the same operands a few
lines later, so that site executes and its result is discarded. It is left in
place as the symmetric form, but it is not load-bearing and no test can make it so.

The three suites shared a census and two tree walks by copy; `_gesture-handler.ts`
holds one of each. It imports neither `react-native` nor gesture-handler at module
scope, because the rewrite suite mocks the former and a module-scope import would
resolve gesture-handler through the mock and change what the census means.
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