From c60b18bbcb7efa78145d1cc515ad91b202aa1f69 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 7 Aug 2026 15:41:52 +0300 Subject: [PATCH 1/7] feat: interop react-native-gesture-handler's Pressable and button family 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`. --- package.json | 13 ++ .../react-native-gesture-handler.test.tsx | 150 ++++++++++++++++++ .../react-native-gesture-handler.native.tsx | 110 +++++++++++++ .../react-native-gesture-handler.tsx | 1 + src/metro/resolver.ts | 6 + types.d.ts | 15 ++ yarn.lock | 47 ++++++ 7 files changed, 342 insertions(+) create mode 100644 src/__tests__/native/react-native-gesture-handler.test.tsx create mode 100644 src/components/react-native-gesture-handler.native.tsx create mode 100644 src/components/react-native-gesture-handler.tsx diff --git a/package.json b/package.json index 29c04232..b1fc538c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,18 @@ "types": "./dist/typescript/commonjs/src/components/index.d.ts" } }, + "./components/react-native-gesture-handler": { + "source": "./src/components/react-native-gesture-handler.native.tsx", + "react-native": "./src/components/react-native-gesture-handler.native.tsx", + "import": { + "types": "./dist/typescript/module/src/components/react-native-gesture-handler.d.ts", + "default": "./dist/module/components/react-native-gesture-handler.js" + }, + "require": { + "types": "./dist/typescript/commonjs/src/components/react-native-gesture-handler.d.ts", + "default": "./dist/commonjs/components/react-native-gesture-handler.js" + } + }, "./components/react-native-safe-area-context": { "source": "./src/components/react-native-safe-area-context.native.tsx", "react-native": "./src/components/react-native-safe-area-context.native.tsx", @@ -241,6 +253,7 @@ "react": "19.1.0", "react-native": "0.81.4", "react-native-builder-bob": "^0.43.0", + "react-native-gesture-handler": "2.28.0", "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "5.6.1", "react-native-worklets": "~0.5.0", diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx new file mode 100644 index 00000000..c398d035 --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -0,0 +1,150 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import * as StyledRNGH from "react-native-css/components/react-native-gesture-handler"; +import { registerCSS, testID } from "react-native-css/jest"; +import * as RNGH from "react-native-gesture-handler"; + +/** + * `react-native-gesture-handler` renders views this library's `react-native` + * rewrite never reaches, so `className` was dropped on every component below. + * Each test registers a declaration no other test uses, so "the style reached + * the tree" is an exact claim rather than a coincidence. + */ + +interface RenderedNode { + props: Record; + children: RenderedNode[] | null; +} + +function collectProps(node: unknown): Record[] { + if (node === null || typeof node !== "object") { + return []; + } + + if (Array.isArray(node)) { + return node.flatMap((child) => collectProps(child)); + } + + const { props, children } = node as RenderedNode; + + return [props, ...collectProps(children)]; +} + +function flattenStyles(node: unknown): Record[] { + return collectProps(node).flatMap((props) => { + const style: unknown = props.style; + + if (Array.isArray(style)) { + return style.filter( + (entry): entry is Record => + entry !== null && typeof entry === "object", + ); + } + + return style !== null && typeof style === "object" + ? [style as Record] + : []; + }); +} + +/** Every component this wrapper re-declares, with the width it is styled by. */ +const styledComponents = [ + ["Pressable", 11], + ["RawButton", 12], + ["BaseButton", 13], + ["RectButton", 14], + ["BorderlessButton", 15], +] as const satisfies readonly (readonly [keyof typeof StyledRNGH, number])[]; + +describe.each(styledComponents)("%s", (name, width) => { + const className = `w-${width}`; + + test("resolves className into the rendered style", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + const Component = StyledRNGH[name] as ComponentType< + Record + >; + + const tree = render( + , + ).toJSON(); + + // The touchables merge their own keys into the same object, so the + // claim is that this declaration reached the style — not that it is alone. + expect(flattenStyles(tree)).toContainEqual( + expect.objectContaining({ width }), + ); + }); + + test("never forwards className to a rendered element", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + const Component = StyledRNGH[name] as ComponentType< + Record + >; + + const tree = render( + , + ).toJSON(); + + for (const props of collectProps(tree)) { + expect(props).not.toHaveProperty("className"); + } + }); + + test("the unwrapped component drops className — the bug this closes", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + const Component = RNGH[name] as ComponentType>; + + const tree = render( + , + ).toJSON(); + + expect(flattenStyles(tree)).not.toContainEqual( + expect.objectContaining({ width }), + ); + }); +}); + +test("re-exports the members it does not re-declare", () => { + // `createNativeWrapper` forwards unclaimed props to a React Native primitive + // and `Text` renders one directly, so the `react-native` rewrite already + // reaches these. Re-wrapping them would style the handler, not the view. + for (const name of [ + "FlatList", + "ScrollView", + "Switch", + "Text", + "TextInput", + ] as const) { + expect(StyledRNGH[name]).toBe(RNGH[name]); + } + + // `className` is dropped on the touchables too, but gesture-handler + // deprecates all four in favour of `Pressable`, so they are left untouched. + // `DrawerLayout` and `Swipeable` take no plain `style` prop, so their mapping + // would be a design decision rather than a mechanical one. + + // The gesture API itself must survive the re-export untouched. + expect(StyledRNGH.Gesture).toBe(RNGH.Gesture); + expect(StyledRNGH.GestureDetector).toBe(RNGH.GestureDetector); + expect(StyledRNGH.GestureHandlerRootView).toBe(RNGH.GestureHandlerRootView); +}); + +test("Pressable styles the native button it renders", () => { + registerCSS(`.pressable-target { width: 21px; }`); + + const rendered = render( + , + ).getByTestId(testID); + + // The measured defect: these classes reached no pixel because `className` + // fell into RNGH's `...remainingProps` spread and onto a codegen'd native + // component that declares no such prop. + expect(flattenStyles(rendered)).toContainEqual( + expect.objectContaining({ width: 21 }), + ); +}); diff --git a/src/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx new file mode 100644 index 00000000..bcb0de34 --- /dev/null +++ b/src/components/react-native-gesture-handler.native.tsx @@ -0,0 +1,110 @@ +import { + useCssElement, + type StyledConfiguration, + type StyledProps, +} from "react-native-css"; +import { + BaseButton as RNGHBaseButton, + BorderlessButton as RNGHBorderlessButton, + Pressable as RNGHPressable, + RawButton as RNGHRawButton, + RectButton as RNGHRectButton, + type BaseButtonProps, + type BorderlessButtonProps, + type PressableProps, + type RawButtonProps, + type RectButtonProps, +} from "react-native-gesture-handler"; + +import { copyComponentProperties } from "./copyComponentProperties"; + +export * from "react-native-gesture-handler"; + +/** + * `Pressable` and the button family render `GestureHandlerButton` — a codegen'd + * native component — so the `react-native` rewrite never reaches them and + * `className` falls through their prop spread onto a view that declares no such + * prop. Each one does forward `style`, which is what these mappings target. + * + * The components NOT re-declared here are already className-aware, and + * re-wrapping them would style the gesture handler's own wrapper rather than + * the view: `ScrollView`, `Switch`, `TextInput` and `FlatList` are built with + * `createNativeWrapper`, which forwards every prop it does not claim for the + * handler down to a React Native primitive, and `Text` renders one directly. + * + * Three groups are deliberately left alone: + * + * - **The touchables.** `className` is dropped on them too, but gesture-handler + * deprecates all four in favour of `Pressable`, so wrapping them would add a + * surface that is scheduled for removal. `TouchableNativeFeedback` has a + * second reason: only its `.android` variant is gesture-handler's own, and + * every other platform re-exports React Native's, which the rewrite already + * reaches. + * - **`DrawerLayout` and `Swipeable`.** Neither takes a plain `style` prop — + * they expose `containerStyle`, `childrenContainerStyle` and + * `drawerContainerStyle` — so which one `className` should target is a design + * decision rather than a mechanical mapping. + * - **`DrawerLayoutAndroid` and `RefreshControl`.** Gesture Handler builds both + * with `createNativeWrapper` too, so they would inherit the rewrite like the + * rest of that group — except `components/index.cts` re-exports these two + * straight from `react-native` instead of styling them, so there is no styled + * twin for them to inherit from. + */ +const pressableMapping: StyledConfiguration = { + className: "style", +}; + +export const Pressable = copyComponentProperties( + RNGHPressable, + (props: StyledProps) => { + return useCssElement(RNGHPressable, props, pressableMapping); + }, +); + +const rawButtonMapping: StyledConfiguration = { + className: "style", +}; + +export const RawButton = copyComponentProperties( + RNGHRawButton, + (props: StyledProps) => { + return useCssElement(RNGHRawButton, props, rawButtonMapping); + }, +); + +const baseButtonMapping: StyledConfiguration = { + className: "style", +}; + +export const BaseButton = copyComponentProperties( + RNGHBaseButton, + (props: StyledProps) => { + return useCssElement(RNGHBaseButton, props, baseButtonMapping); + }, +); + +const rectButtonMapping: StyledConfiguration = { + className: "style", +}; + +export const RectButton = copyComponentProperties( + RNGHRectButton, + (props: StyledProps) => { + return useCssElement(RNGHRectButton, props, rectButtonMapping); + }, +); + +const borderlessButtonMapping: StyledConfiguration< + typeof RNGHBorderlessButton +> = { + className: "style", +}; + +export const BorderlessButton = copyComponentProperties( + RNGHBorderlessButton, + ( + props: StyledProps, + ) => { + return useCssElement(RNGHBorderlessButton, props, borderlessButtonMapping); + }, +); diff --git a/src/components/react-native-gesture-handler.tsx b/src/components/react-native-gesture-handler.tsx new file mode 100644 index 00000000..667f1fa4 --- /dev/null +++ b/src/components/react-native-gesture-handler.tsx @@ -0,0 +1 @@ +export * from "react-native-gesture-handler"; diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts index 05c6dac8..1794cbf9 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -41,6 +41,12 @@ export function nativeResolver( `react-native-css/components/react-native-safe-area-context`, platform, ); + } else if (moduleName === "react-native-gesture-handler") { + return resolver( + context, + `react-native-css/components/react-native-gesture-handler`, + platform, + ); } else if ( resolution.filePath.includes(`${sep}react-native${sep}Libraries${sep}`) ) { diff --git a/types.d.ts b/types.d.ts index 1ba5b856..981e0684 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,6 +15,21 @@ declare module "@react-native/virtualized-lists" { } } +declare module "react-native-gesture-handler" { + // `BaseButtonProps`, `RectButtonProps` and `BorderlessButtonProps` all + // extend this one. `PressableProps` reaches `className` through `ViewProps` + // instead, but the button family extends neither that nor + // `TouchableWithoutFeedbackProps`. + // + // Both are declared `| undefined` because a conditional + // `className={x ? a : undefined}` spreads an explicit `undefined`, which a + // bare `?: string` forbids under `exactOptionalPropertyTypes`. + interface RawButtonProps { + className?: string | undefined; + cssInterop?: boolean | undefined; + } +} + declare module "react-native" { interface ButtonProps { className?: string; diff --git a/yarn.lock b/yarn.lock index 8fe7596e..5715e19a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2658,6 +2658,15 @@ __metadata: languageName: node linkType: hard +"@egjs/hammerjs@npm:^2.0.17": + version: 2.0.17 + resolution: "@egjs/hammerjs@npm:2.0.17" + dependencies: + "@types/hammerjs": "npm:^2.0.36" + checksum: 10c0/dbedc15a0e633f887c08394bd636faf6a3abd05726dc0909a0e01209d5860a752d9eca5e512da623aecfabe665f49f1d035de3103eb2f9022c5cea692f9cc9be + languageName: node + linkType: hard + "@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.4.5": version: 1.5.0 resolution: "@emnapi/core@npm:1.5.0" @@ -4755,6 +4764,13 @@ __metadata: languageName: node linkType: hard +"@types/hammerjs@npm:^2.0.36": + version: 2.0.46 + resolution: "@types/hammerjs@npm:2.0.46" + checksum: 10c0/f3c1cb20dc2f0523f7b8c76065078544d50d8ae9b0edc1f62fed657210ed814266ff2dfa835d2c157a075991001eec3b64c88bf92e3e6e895c0db78d05711d06 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -8433,6 +8449,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.0": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -12180,6 +12205,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^16.7.0": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 + languageName: node + linkType: hard + "react-is@npm:^18.0.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -12297,6 +12329,7 @@ __metadata: react: "npm:19.1.0" react-native: "npm:0.81.4" react-native-builder-bob: "npm:^0.43.0" + react-native-gesture-handler: "npm:2.28.0" react-native-reanimated: "npm:~4.1.0" react-native-safe-area-context: "npm:5.6.1" react-native-worklets: "npm:~0.5.0" @@ -12315,6 +12348,20 @@ __metadata: languageName: unknown linkType: soft +"react-native-gesture-handler@npm:2.28.0": + version: 2.28.0 + resolution: "react-native-gesture-handler@npm:2.28.0" + dependencies: + "@egjs/hammerjs": "npm:^2.0.17" + hoist-non-react-statics: "npm:^3.3.0" + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/4240c8eedca69eb36b5d3e375b71867251cf8b87a755ba7066b3f73cfdbc80574042dbd4ff821041fd1539c4cd90dbf7ee34586f5a0ea6cc38052375b3169f2e + languageName: node + linkType: hard + "react-native-is-edge-to-edge@npm:^1.2.1": version: 1.2.1 resolution: "react-native-is-edge-to-edge@npm:1.2.1" From 1d03e432828bb8c3727be4a72da74c5f32918b12 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 20:37:49 +0300 Subject: [PATCH 2/7] docs: trim comments to the surrounding one-line style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../react-native-gesture-handler.test.tsx | 8 +--- .../react-native-gesture-handler.native.tsx | 39 +++++++------------ types.d.ts | 11 ++---- 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx index c398d035..82768b3d 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -5,12 +5,8 @@ import * as StyledRNGH from "react-native-css/components/react-native-gesture-ha import { registerCSS, testID } from "react-native-css/jest"; import * as RNGH from "react-native-gesture-handler"; -/** - * `react-native-gesture-handler` renders views this library's `react-native` - * rewrite never reaches, so `className` was dropped on every component below. - * Each test registers a declaration no other test uses, so "the style reached - * the tree" is an exact claim rather than a coincidence. - */ +// Each test registers a declaration no other test uses, so "the style reached the +// tree" is an exact claim rather than a coincidence interface RenderedNode { props: Record; diff --git a/src/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx index bcb0de34..3846cb25 100644 --- a/src/components/react-native-gesture-handler.native.tsx +++ b/src/components/react-native-gesture-handler.native.tsx @@ -21,34 +21,21 @@ import { copyComponentProperties } from "./copyComponentProperties"; export * from "react-native-gesture-handler"; /** - * `Pressable` and the button family render `GestureHandlerButton` — a codegen'd - * native component — so the `react-native` rewrite never reaches them and - * `className` falls through their prop spread onto a view that declares no such - * prop. Each one does forward `style`, which is what these mappings target. + * Pressable and the button family render GestureHandlerButton, a codegen'd native + * component, so the react-native rewrite never reaches them and className falls onto a + * view that declares no such prop. Each forwards `style`, which these mappings target. * - * The components NOT re-declared here are already className-aware, and - * re-wrapping them would style the gesture handler's own wrapper rather than - * the view: `ScrollView`, `Switch`, `TextInput` and `FlatList` are built with - * `createNativeWrapper`, which forwards every prop it does not claim for the - * handler down to a React Native primitive, and `Text` renders one directly. + * Not re-declared, and why: * - * Three groups are deliberately left alone: - * - * - **The touchables.** `className` is dropped on them too, but gesture-handler - * deprecates all four in favour of `Pressable`, so wrapping them would add a - * surface that is scheduled for removal. `TouchableNativeFeedback` has a - * second reason: only its `.android` variant is gesture-handler's own, and - * every other platform re-exports React Native's, which the rewrite already - * reaches. - * - **`DrawerLayout` and `Swipeable`.** Neither takes a plain `style` prop — - * they expose `containerStyle`, `childrenContainerStyle` and - * `drawerContainerStyle` — so which one `className` should target is a design - * decision rather than a mechanical mapping. - * - **`DrawerLayoutAndroid` and `RefreshControl`.** Gesture Handler builds both - * with `createNativeWrapper` too, so they would inherit the rewrite like the - * rest of that group — except `components/index.cts` re-exports these two - * straight from `react-native` instead of styling them, so there is no styled - * twin for them to inherit from. + * - ScrollView, Switch, TextInput, FlatList, Text — already className-aware; wrapping + * them would style the gesture handler's wrapper rather than the view. + * - The four touchables — className is dropped there too, but gesture-handler deprecates + * them in favour of Pressable. TouchableNativeFeedback is additionally gesture-handler's + * own only on Android; elsewhere it re-exports React Native's, which the rewrite reaches. + * - DrawerLayout, Swipeable — no plain `style` prop, only containerStyle / + * childrenContainerStyle / drawerContainerStyle, so the target is a design decision. + * - DrawerLayoutAndroid, RefreshControl — components/index.cts re-exports these straight + * from react-native, so there is no styled twin for them to inherit from. */ const pressableMapping: StyledConfiguration = { className: "style", diff --git a/types.d.ts b/types.d.ts index 981e0684..3bbe798a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -16,14 +16,9 @@ declare module "@react-native/virtualized-lists" { } declare module "react-native-gesture-handler" { - // `BaseButtonProps`, `RectButtonProps` and `BorderlessButtonProps` all - // extend this one. `PressableProps` reaches `className` through `ViewProps` - // instead, but the button family extends neither that nor - // `TouchableWithoutFeedbackProps`. - // - // Both are declared `| undefined` because a conditional - // `className={x ? a : undefined}` spreads an explicit `undefined`, which a - // bare `?: string` forbids under `exactOptionalPropertyTypes`. + // BaseButtonProps, RectButtonProps and BorderlessButtonProps all extend this one. + // PressableProps reaches className through ViewProps instead; the button family + // extends neither that nor TouchableWithoutFeedbackProps interface RawButtonProps { className?: string | undefined; cssInterop?: boolean | undefined; From 6531a570f785e845a49199248ea89d2aa3eb0f69 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:27:38 +0300 Subject: [PATCH 3/7] fix: keep a callback style prop a callback through the className merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 ` …} />`: `[{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. --- .../native/className-with-style.test.tsx | 55 +++++++++++++++++++ src/native/styles/index.ts | 50 +++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 8896f3b4..fc5c20af 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -3,6 +3,7 @@ import { View as RNView } from "react-native"; import { render } from "@testing-library/react-native"; import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; import { FlatList } from "react-native-css/components/FlatList"; +import { Pressable } from "react-native-css/components/Pressable"; import { ScrollView } from "react-native-css/components/ScrollView"; import { Text } from "react-native-css/components/Text"; import { View } from "react-native-css/components/View"; @@ -68,6 +69,60 @@ test("important should overwrite the inline style", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); +describe("a callback style prop stays a callback", () => { + // `Pressable` declares `style` as either styles or `(state) => styles`, and picks + // between them with `typeof style === "function"`. Merging className into an array + // answers "object" there, so the callback never runs and the raw function reaches + // the view — every pressed-state style silently dropped. + + test("Pressable: className with a callback style", () => { + registerCSS(`.text-red { color: red; }`); + + const component = render( + ({ opacity: pressed ? 0.5 : 1 })} + />, + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual([ + { color: "#f00" }, + { opacity: 1 }, + ]); + }); + + test("Pressable: important className with a callback style", () => { + registerCSS(`.bg-red\\! { background-color: red !important; }`); + + const component = render( + ({ backgroundColor: "blue" })} + />, + ).getByTestId(testID); + + // The callback ran, and the important declaration is the rightmost entry, + // so it still wins over what the callback returned. + expect(component.props.style).toStrictEqual([ + { backgroundColor: "blue" }, + { backgroundColor: "#f00" }, + ]); + }); + + test("Pressable: a callback style with no className is untouched", () => { + const component = render( + ({ opacity: pressed ? 0.5 : 1 })} + />, + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ opacity: 1 }); + }); +}); + test("View with multiple className properties where inline style takes precedence", () => { registerCSS(` .px-4 { padding-left: 16px; padding-right: 16px; } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index c598fc6b..a67f0aa1 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -352,6 +352,27 @@ function mergeDefinedProps( return result; } +type StyleCallback = (state: unknown) => unknown; + +function isStyleCallback(value: unknown): value is StyleCallback { + return typeof value === "function"; +} + +/** + * `Pressable` declares `style` as either styles or a callback taking its pressed state, + * and invokes it with `typeof style === "function"`. Merging a callback into an array + * would answer "object" there, so the callback would never run and the unevaluated + * function would reach the native component — every pressed-state style silently gone. + * Composing into a new callback keeps the shape the consumer switches on, and applies + * the same left-then-right precedence the array form would have. + */ +function composeStyleCallback(left: unknown, right: unknown): StyleCallback { + return (state) => [ + isStyleCallback(left) ? left(state) : left, + isStyleCallback(right) ? right(state) : right, + ]; +} + function deepMergeConfig( config: Config, left: Record | undefined, @@ -396,7 +417,15 @@ function deepMergeConfig( typeof filteredRightStyle === "object" && !Array.isArray(filteredRightStyle); - if (leftIsObject && rightIsObject) { + if ( + isStyleCallback(leftStyle) || + isStyleCallback(filteredRightStyle) + ) { + result.style = composeStyleCallback( + leftStyle, + filteredRightStyle, + ); + } else if (leftIsObject && rightIsObject) { if (hasNonOverlappingProperties(leftStyle, filteredRightStyle)) { result.style = [leftStyle, filteredRightStyle]; } else { @@ -420,8 +449,9 @@ function deepMergeConfig( } else if (!rightIsInline && right?.style) { // Merging non-inline styles (e.g., important styles) if (left?.style) { - // If left.style is an array, append right.style - if (Array.isArray(left.style)) { + if (isStyleCallback(left.style) || isStyleCallback(right.style)) { + result.style = composeStyleCallback(left.style, right.style); + } else if (Array.isArray(left.style)) { const combined = [...left.style, right.style]; result.style = flattenStyleArray(combined); } else if ( @@ -504,7 +534,9 @@ function deepMergeConfig( typeof rightValue === "object" && rightValue !== null && !Array.isArray(rightValue); - if (leftIsObj && rightIsObj) { + if (isStyleCallback(leftValue) || isStyleCallback(rightValue)) { + result[finalKey] = composeStyleCallback(leftValue, rightValue); + } else if (leftIsObj && rightIsObj) { if (hasNonOverlappingProperties(leftValue, rightValue)) { result[finalKey] = [leftValue, rightValue]; } else { @@ -537,8 +569,14 @@ function deepMergeConfig( } if (rightValue !== undefined) { - result[target] = - left && target in left ? [left[target], rightValue] : rightValue; + if (left && target in left) { + result[target] = + isStyleCallback(left[target]) || isStyleCallback(rightValue) + ? composeStyleCallback(left[target], rightValue) + : [left[target], rightValue]; + } else { + result[target] = rightValue; + } } return result; From 1272b61022864195de5524f3c9f3e71d9298a1e5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:27:59 +0300 Subject: [PATCH 4/7] test: cover the Metro resolver, and anchor its own-package exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/__tests__/metro/resolver.test.ts | 240 +++++++++++++++++++++++++++ src/metro/resolver.ts | 18 +- 2 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/metro/resolver.test.ts diff --git a/src/__tests__/metro/resolver.test.ts b/src/__tests__/metro/resolver.test.ts new file mode 100644 index 00000000..9cd18709 --- /dev/null +++ b/src/__tests__/metro/resolver.test.ts @@ -0,0 +1,240 @@ +import { join, resolve, sep } from "node:path"; + +import type { + CustomResolutionContext, + CustomResolver, + Resolution, +} from "metro-resolver"; + +import { nativeResolver, webResolver } from "../../metro/resolver"; + +const packageRoot = resolve(__dirname, "../../.."); +const packageSource = join(packageRoot, "src"); +const nodeModules = join(packageRoot, "node_modules"); + +/** + * `nativeResolver` only ever reads `originModulePath` off the context and hands + * the whole thing back to the resolver it was given, so the rest of Metro's + * `ResolutionContext` never has to exist for these. + */ +function contextFor(originModulePath: string): CustomResolutionContext { + return { originModulePath } as unknown as CustomResolutionContext; +} + +interface Recorder { + readonly resolver: CustomResolver; + readonly calls: [moduleName: string, platform: string | null][]; +} + +/** + * Resolves every request to a plausible source file so the resolver under test + * takes its `resolution.type === "sourceFile"` path, and records what it was + * asked for. `filePath` is derived from the request, which is what lets the + * `react-native/Libraries/*` branch be driven without a node_modules tree. + */ +function recordingResolver( + filePathFor?: (moduleName: string) => string, +): Recorder { + const calls: [string, string | null][] = []; + + const resolver: CustomResolver = (_context, moduleName, platform) => { + calls.push([moduleName, platform]); + + return { + type: "sourceFile", + filePath: + filePathFor?.(moduleName) ?? join(nodeModules, moduleName, "index.js"), + } satisfies Resolution; + }; + + return { resolver, calls }; +} + +describe("nativeResolver", () => { + const thirdParty = join(nodeModules, "some-library", "index.js"); + + test.each([ + ["react-native", "react-native-css/components"], + [ + "react-native-safe-area-context", + "react-native-css/components/react-native-safe-area-context", + ], + [ + "react-native-gesture-handler", + "react-native-css/components/react-native-gesture-handler", + ], + ])("rewrites %s to %s", (moduleName, rewritten) => { + const { resolver, calls } = recordingResolver(); + + const resolution = nativeResolver( + resolver, + contextFor(thirdParty), + moduleName, + "ios", + ); + + expect(calls).toEqual([ + [moduleName, "ios"], + [rewritten, "ios"], + ]); + expect(resolution).toEqual({ + type: "sourceFile", + filePath: join(nodeModules, rewritten, "index.js"), + }); + }); + + test("rewrites a react-native Libraries module to its styled twin", () => { + const { resolver, calls } = recordingResolver((moduleName) => + moduleName === "react-native/Libraries/Components/View/View" + ? join( + nodeModules, + "react-native", + "Libraries", + "Components", + "View", + "View.js", + ) + : join(nodeModules, moduleName, "index.js"), + ); + + nativeResolver( + resolver, + contextFor(thirdParty), + "react-native/Libraries/Components/View/View", + "android", + ); + + expect(calls.at(-1)).toEqual([ + "react-native-css/components/View", + "android", + ]); + }); + + test("leaves a Libraries module with no styled twin alone", () => { + const { resolver, calls } = recordingResolver(() => + join( + nodeModules, + "react-native", + "Libraries", + "Utilities", + "Platform.js", + ), + ); + + nativeResolver( + resolver, + contextFor(thirdParty), + "react-native/Libraries/Utilities/Platform", + "ios", + ); + + expect(calls).toHaveLength(1); + }); + + test.each([ + ["this package's source", join(packageSource, "components", "View.tsx")], + [ + "this package's build output", + join(packageRoot, "dist", "module", "index.js"), + ], + ["react-native's own index", join(nodeModules, "react-native", "index.js")], + ])("leaves an import from %s alone", (_label, originModulePath) => { + const { resolver, calls } = recordingResolver(); + + const resolution = nativeResolver( + resolver, + contextFor(originModulePath), + "react-native-gesture-handler", + "ios", + ); + + // Rewriting here would send this package's own modules — or react-native's + // index — back through the wrapper that imports them, a resolution cycle. + expect(calls).toEqual([["react-native-gesture-handler", "ios"]]); + expect(resolution).toEqual({ + type: "sourceFile", + filePath: join(nodeModules, "react-native-gesture-handler", "index.js"), + }); + }); + + test("leaves a resolution that is not a source file alone", () => { + const calls: [string, string | null][] = []; + const resolver: CustomResolver = (_context, moduleName, platform) => { + calls.push([moduleName, platform]); + return { type: "empty" } satisfies Resolution; + }; + + expect( + nativeResolver( + resolver, + contextFor(thirdParty), + "react-native-gesture-handler", + "ios", + ), + ).toEqual({ type: "empty" }); + expect(calls).toEqual([["react-native-gesture-handler", "ios"]]); + }); + + test("leaves an unrelated module alone", () => { + const { resolver, calls } = recordingResolver(); + + nativeResolver(resolver, contextFor(thirdParty), "lodash", null); + + expect(calls).toEqual([["lodash", null]]); + }); +}); + +describe("webResolver", () => { + const thirdParty = join(nodeModules, "some-library", "index.js"); + + test("rewrites a react-native-web component to its styled twin", () => { + const { resolver, calls } = recordingResolver(() => + join( + nodeModules, + "react-native-web", + "dist", + "exports", + "View", + "index.js", + ), + ); + + webResolver(resolver, contextFor(thirdParty), "react-native", "web"); + + expect(calls.at(-1)).toEqual(["react-native-css/components/View", "web"]); + }); + + test("leaves react-native-web's own vendor files alone", () => { + const { resolver, calls } = recordingResolver(() => + [ + nodeModules, + "react-native-web", + "dist", + "vendor", + "View", + "index.js", + ].join(sep), + ); + + webResolver(resolver, contextFor(thirdParty), "react-native", "web"); + + expect(calls).toHaveLength(1); + }); + + test("leaves VirtualizedList alone", () => { + const { resolver, calls } = recordingResolver(() => + join( + nodeModules, + "react-native-web", + "dist", + "exports", + "VirtualizedList", + "index.js", + ), + ); + + webResolver(resolver, contextFor(thirdParty), "react-native", "web"); + + expect(calls).toHaveLength(1); + }); +}); diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts index 1794cbf9..adc7bf06 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -1,4 +1,4 @@ -import { basename, resolve, sep } from "node:path"; +import { basename, dirname, join, resolve, sep } from "node:path"; import type { CustomResolutionContext, @@ -8,8 +8,20 @@ import type { import { allowedModules } from "../babel/allowedModules"; -const thisModuleDist = resolve(__dirname, "../../../dist"); -const thisModuleSrc = resolve(__dirname, "../../../src"); +/** + * `__dirname` is `/dist//metro` once bob has built + * this, and `/src/metro` when the `source` export condition wins. + * Anchoring on the segment that names the layout rather than on a fixed number + * of levels keeps the exemption below true either way — and an exemption that + * misses is a resolution cycle, since it sends this package's own components + * back through the wrapper that imports them. + */ +const packageRoot = resolve( + __dirname, + basename(dirname(__dirname)) === "src" ? "../.." : "../../..", +); +const thisModuleDist = join(packageRoot, "dist"); +const thisModuleSrc = join(packageRoot, "src"); function isFromThisModule(filename: string): boolean { return ( From 284f4f4cb5f6fe99c141291e38829af16ae67506 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:28:24 +0300 Subject: [PATCH 5/7] fix: complete the gesture-handler exclusion register, and derive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- ...ct-native-gesture-handler-rewrite.test.tsx | 123 +++++++ .../react-native-gesture-handler.test.tsx | 301 ++++++++++++++---- .../react-native-gesture-handler.native.tsx | 69 +++- 3 files changed, 414 insertions(+), 79 deletions(-) create mode 100644 src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx diff --git a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx new file mode 100644 index 00000000..33282a7d --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx @@ -0,0 +1,123 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { registerCSS, testID } from "react-native-css/jest"; + +/** + * `nativeResolver` rewrites every `react-native` import outside this package to + * `react-native-css/components` — react-native's own exports with the styled + * components layered over them — and leaves this package's own files alone + * (`isFromThisModule`), so the styled components themselves are built against + * the real react-native. The re-entrancy flag below is that second half. + * + * Without it these five drop `className` outright, which is why the sibling + * suite's object-identity assertions cannot stand in for this file: identity is + * what `export *` guarantees by construction, and says nothing about whether the + * re-export reaches a pixel. + */ +let mockRewriting = false; +jest.mock("react-native", (): Record => { + if (mockRewriting) { + return jest.requireActual>("react-native"); + } + + mockRewriting = true; + try { + return jest.requireActual>( + "react-native-css/components", + ); + } finally { + mockRewriting = false; + } +}); + +interface RenderedNode { + props: Record; + children: RenderedNode[] | null; +} + +function collectProps(node: unknown): Record[] { + if (node === null || typeof node !== "object") { + return []; + } + + if (Array.isArray(node)) { + return node.flatMap((child) => collectProps(child)); + } + + const { props, children } = node as RenderedNode; + + return [props, ...collectProps(children)]; +} + +function flattenStyles(node: unknown): Record[] { + const collect = (style: unknown): Record[] => { + if (Array.isArray(style)) { + return style.flatMap((entry) => collect(entry)); + } + + return style !== null && typeof style === "object" + ? [style as Record] + : []; + }; + + return collectProps(node).flatMap((props) => collect(props.style)); +} + +function styledGestureHandler(): Record { + return jest.requireActual>( + "react-native-css/components/react-native-gesture-handler", + ); +} + +test("the rewrite is in effect", () => { + // Every assertion below is vacuous if `react-native` resolves to itself here, + // and the whole file would pass while measuring nothing. + const rewritten = jest.requireMock>("react-native"); + const real = jest.requireActual>("react-native"); + + expect(rewritten.View).not.toBe(real.View); + expect(rewritten.Dimensions).toBe(real.Dimensions); +}); + +describe.each([ + ["ScrollView", 31, {}], + ["Switch", 32, {}], + ["TextInput", 33, {}], + ["FlatList", 34, { data: [], renderItem: () => null }], + ["Text", 35, {}], +])("%s", (name, width, extra: Record) => { + test("resolves className through the rewrite, so it needs no re-declaration", () => { + registerCSS(`.w-${width} { width: ${width}px; }`); + + const Component = styledGestureHandler()[name] as ComponentType< + Record + >; + const tree = render( + , + ).toJSON(); + + expect(flattenStyles(tree)).toContainEqual( + expect.objectContaining({ width }), + ); + + for (const props of collectProps(tree)) { + expect(props).not.toHaveProperty("className"); + } + }); +}); + +test("ScrollView's contentContainerClassName survives the rewrite too", () => { + registerCSS(`.w-52 { width: 52px; }`); + + const ScrollView = styledGestureHandler().ScrollView as ComponentType< + Record + >; + const tree = render( + , + ).toJSON(); + + expect( + collectProps(tree).map((props) => props.contentContainerStyle), + ).toContainEqual({ width: 52 }); +}); diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx index 82768b3d..982ff7a8 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -5,9 +5,6 @@ import * as StyledRNGH from "react-native-css/components/react-native-gesture-ha import { registerCSS, testID } from "react-native-css/jest"; import * as RNGH from "react-native-gesture-handler"; -// Each test registers a declaration no other test uses, so "the style reached the -// tree" is an exact claim rather than a coincidence - interface RenderedNode { props: Record; children: RenderedNode[] | null; @@ -27,45 +24,148 @@ function collectProps(node: unknown): Record[] { return [props, ...collectProps(children)]; } +/** + * Every style object in the tree, at any array depth. The merge nests — a Pressable + * carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a + * single level would report an absence that is really a depth. + */ function flattenStyles(node: unknown): Record[] { - return collectProps(node).flatMap((props) => { - const style: unknown = props.style; - + const collect = (style: unknown): Record[] => { if (Array.isArray(style)) { - return style.filter( - (entry): entry is Record => - entry !== null && typeof entry === "object", - ); + return style.flatMap((entry) => collect(entry)); } return style !== null && typeof style === "object" ? [style as Record] : []; - }); + }; + + return collectProps(node).flatMap((props) => collect(props.style)); } -/** Every component this wrapper re-declares, with the width it is styled by. */ -const styledComponents = [ - ["Pressable", 11], - ["RawButton", 12], - ["BaseButton", 13], - ["RectButton", 14], - ["BorderlessButton", 15], -] as const satisfies readonly (readonly [keyof typeof StyledRNGH, number])[]; +const styledExports = StyledRNGH as unknown as Record; +const gestureHandlerExports = RNGH as unknown as Record; + +/** + * Derived from the module, not restated: a member this wrapper re-declares is one whose + * export is no longer the one `export *` would have provided. Every case below is + * generated from this, so a sixth re-declaration is covered the moment it lands. + */ +const reDeclared: string[] = Object.keys(styledExports) + .filter((name) => styledExports[name] !== gestureHandlerExports[name]) + .sort(); + +/** + * The exclusion register, executable. Every remaining gesture-handler export sits in one + * of these groups with its reason, so a member can be neither re-declared nor excluded + * only by failing the accounting test below — which is how `PureNativeButton`, a sixth + * member of the button family, went unnoticed. + */ +const notAComponent = [ + "Directions", + "Gesture", + "GestureDetector", + "GestureHandlerRootView", + "HoverEffect", + "MouseButton", + "PointerType", + "State", + "createNativeWrapper", + "enableExperimentalWebImplementation", + "enableLegacyWebImplementation", + "gestureHandlerRootHOC", +]; + +/** Handlers wrap a child; they render no view of their own for a style to land on. */ +const gestureHandlers = [ + "FlingGestureHandler", + "ForceTouchGestureHandler", + "LongPressGestureHandler", + "NativeViewGestureHandler", + "PanGestureHandler", + "PinchGestureHandler", + "RotationGestureHandler", + "TapGestureHandler", +]; + +/** Reached by the `react-native` rewrite already — see the rewrite test alongside this. */ +const reachedByTheRewrite = [ + "FlatList", + "ScrollView", + "Switch", + "Text", + "TextInput", +]; + +/** className is dropped, and gesture-handler marks every one `@deprecated`. */ +const deprecatedByGestureHandler = [ + "DrawerLayout", + "Swipeable", + "TouchableHighlight", + "TouchableNativeFeedback", + "TouchableOpacity", + "TouchableWithoutFeedback", +]; + +/** className is dropped, and no test at this tier can observe a fix — see below. */ +const unobservable = ["RefreshControl"]; + +/** Props a component will not render at all without. */ +const requiredProps: Record> = { + DrawerLayout: { renderNavigationView: () => null }, + DrawerLayoutAndroid: { renderNavigationView: () => null }, +}; + +/** Each component gets a width no other test uses, so "the style reached the tree" is exact. */ +const cases: [name: string, width: number][] = reDeclared.map((name, index) => [ + name, + 11 + index, +]); + +function renderWith( + component: unknown, + props: Record, +): unknown { + const Component = component as ComponentType>; + + return render().toJSON(); +} -describe.each(styledComponents)("%s", (name, width) => { +test("every gesture-handler export is either re-declared or excluded with a reason", () => { + const accounted = new Set([ + ...reDeclared, + ...notAComponent, + ...gestureHandlers, + ...reachedByTheRewrite, + ...deprecatedByGestureHandler, + ...unobservable, + ]); + + expect( + Object.keys(gestureHandlerExports).filter((name) => !accounted.has(name)), + ).toEqual([]); + + // An empty census would generate no cases at all and every `describe.each` + // below would silently assert nothing. + expect(reDeclared).toEqual([ + "BaseButton", + "BorderlessButton", + "DrawerLayoutAndroid", + "Pressable", + "PureNativeButton", + "RawButton", + "RectButton", + ]); +}); + +describe.each(cases)("%s", (name, width) => { const className = `w-${width}`; + const extra = requiredProps[name] ?? {}; test("resolves className into the rendered style", () => { registerCSS(`.${className} { width: ${width}px; }`); - const Component = StyledRNGH[name] as ComponentType< - Record - >; - - const tree = render( - , - ).toJSON(); + const tree = renderWith(styledExports[name], { className, ...extra }); // The touchables merge their own keys into the same object, so the // claim is that this declaration reached the style — not that it is alone. @@ -77,70 +177,131 @@ describe.each(styledComponents)("%s", (name, width) => { test("never forwards className to a rendered element", () => { registerCSS(`.${className} { width: ${width}px; }`); - const Component = StyledRNGH[name] as ComponentType< - Record - >; - - const tree = render( - , - ).toJSON(); + const tree = renderWith(styledExports[name], { className, ...extra }); for (const props of collectProps(tree)) { expect(props).not.toHaveProperty("className"); } }); - test("the unwrapped component drops className — the bug this closes", () => { + test("the unwrapped component drops the declaration — the bug this closes", () => { registerCSS(`.${className} { width: ${width}px; }`); - const Component = RNGH[name] as ComponentType>; + // Asserting the absence alone would pass over an empty set — `RawButton` + // renders no `style` prop at all — and so would pass with a misspelled class, + // a failed `registerCSS`, or a component that renders nothing. Pinning that + // the same declaration DOES reach the re-declared twin is what makes the + // absence a measurement. + expect( + flattenStyles(renderWith(styledExports[name], { className, ...extra })), + ).toContainEqual(expect.objectContaining({ width })); - const tree = render( - , - ).toJSON(); + expect( + flattenStyles( + renderWith(gestureHandlerExports[name], { className, ...extra }), + ), + ).not.toContainEqual(expect.objectContaining({ width })); + }); - expect(flattenStyles(tree)).not.toContainEqual( - expect.objectContaining({ width }), + test("keeps an inline style beside the className styles", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + const height = 100 + width; + const styles = flattenStyles( + renderWith(styledExports[name], { + className, + style: { height }, + ...extra, + }), + ); + + // The two merge into one object on the button family and into a nested array + // on Pressable; both shapes are reachable and neither value is lost. + expect(styles).toContainEqual(expect.objectContaining({ width })); + expect(styles).toContainEqual(expect.objectContaining({ height })); + }); + + test("renders identically to the unwrapped component when no className is given", () => { + // The resolver routes every gesture-handler import in the graph through this + // module — react-navigation, react-native-screens, bottom-sheet — so the + // no-className path has to stay byte-identical. + expect(JSON.stringify(renderWith(styledExports[name], extra))).toBe( + JSON.stringify(renderWith(gestureHandlerExports[name], extra)), ); }); }); +test("resolves a function style beside className on Pressable", () => { + registerCSS(`.w-46 { width: 46px; }`); + + // `Pressable` calls `style({ pressed })` when it is a function. Merging className + // into an array would leave `typeof style === "object"`, the callback would never + // be invoked, and the raw function would reach the native component — silently + // dropping every pressed-state style the caller wrote. + const tree = renderWith(StyledRNGH.Pressable, { + className: "w-46", + style: ({ pressed }: { pressed: boolean }) => ({ + opacity: pressed ? 0.5 : 1, + }), + }); + const styles = flattenStyles(tree); + + expect(styles).toContainEqual(expect.objectContaining({ width: 46 })); + expect(styles).toContainEqual(expect.objectContaining({ opacity: 1 })); + + for (const props of collectProps(tree)) { + expect(typeof props.style).not.toBe("function"); + } +}); + test("re-exports the members it does not re-declare", () => { - // `createNativeWrapper` forwards unclaimed props to a React Native primitive - // and `Text` renders one directly, so the `react-native` rewrite already - // reaches these. Re-wrapping them would style the handler, not the view. for (const name of [ - "FlatList", - "ScrollView", - "Switch", - "Text", - "TextInput", - ] as const) { - expect(StyledRNGH[name]).toBe(RNGH[name]); + ...notAComponent, + ...gestureHandlers, + ...reachedByTheRewrite, + ...deprecatedByGestureHandler, + ...unobservable, + ]) { + expect(styledExports[name]).toBe(gestureHandlerExports[name]); } +}); - // `className` is dropped on the touchables too, but gesture-handler - // deprecates all four in favour of `Pressable`, so they are left untouched. - // `DrawerLayout` and `Swipeable` take no plain `style` prop, so their mapping - // would be a design decision rather than a mechanical one. +describe.each(deprecatedByGestureHandler)("%s", (name) => { + const extra = requiredProps[name] ?? {}; + + test("drops className — left as-is because gesture-handler deprecates it", () => { + // Pins the exclusion register: these are not re-declared because gesture-handler + // marks them `@deprecated`, NOT because the rewrite reaches them. It does not — + // `components/index.cts` has no styled twin for `TouchableNativeFeedback`, and + // the other five are gesture-handler's own components. + registerCSS(`.w-91 { width: 91px; }`); + + const Component = styledExports[name] as ComponentType< + Record + >; + const tree = render( + + child + , + ).toJSON(); - // The gesture API itself must survive the re-export untouched. - expect(StyledRNGH.Gesture).toBe(RNGH.Gesture); - expect(StyledRNGH.GestureDetector).toBe(RNGH.GestureDetector); - expect(StyledRNGH.GestureHandlerRootView).toBe(RNGH.GestureHandlerRootView); + expect(flattenStyles(tree)).not.toContainEqual( + expect.objectContaining({ width: 91 }), + ); + }); }); -test("Pressable styles the native button it renders", () => { - registerCSS(`.pressable-target { width: 21px; }`); +test("RefreshControl carries no props a test at this tier could read", () => { + // React Native's own jest mock renders `` and drops every + // prop, so no test here can watch a style reach it — react-native-css's own + // components included. This is why the register excludes it rather than mapping + // it; if the mock ever forwards props, this turns red and the decision is retaken. + registerCSS(`.w-92 { width: 92px; }`); - const rendered = render( - , - ).getByTestId(testID); + const tree = renderWith(StyledRNGH.RefreshControl, { + className: "w-92", + refreshing: false, + }); - // The measured defect: these classes reached no pixel because `className` - // fell into RNGH's `...remainingProps` spread and onto a codegen'd native - // component that declares no such prop. - expect(flattenStyles(rendered)).toContainEqual( - expect.objectContaining({ width: 21 }), - ); + expect(collectProps(tree)).toEqual([{}]); }); diff --git a/src/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx index 3846cb25..57be2aaa 100644 --- a/src/components/react-native-gesture-handler.native.tsx +++ b/src/components/react-native-gesture-handler.native.tsx @@ -1,3 +1,5 @@ +import type { ComponentProps } from "react"; + import { useCssElement, type StyledConfiguration, @@ -6,7 +8,9 @@ import { import { BaseButton as RNGHBaseButton, BorderlessButton as RNGHBorderlessButton, + DrawerLayoutAndroid as RNGHDrawerLayoutAndroid, Pressable as RNGHPressable, + PureNativeButton as RNGHPureNativeButton, RawButton as RNGHRawButton, RectButton as RNGHRectButton, type BaseButtonProps, @@ -24,18 +28,30 @@ export * from "react-native-gesture-handler"; * Pressable and the button family render GestureHandlerButton, a codegen'd native * component, so the react-native rewrite never reaches them and className falls onto a * view that declares no such prop. Each forwards `style`, which these mappings target. + * PureNativeButton is that same codegen'd component, exported directly. + * + * DrawerLayoutAndroid is gesture-handler's own `createNativeWrapper` over react-native's, + * and the rewrite hands it react-native's raw component — `components/index.cts` has no + * styled twin to inherit from — so it needs the mapping too. It forwards `style`. * * Not re-declared, and why: * - * - ScrollView, Switch, TextInput, FlatList, Text — already className-aware; wrapping - * them would style the gesture handler's wrapper rather than the view. - * - The four touchables — className is dropped there too, but gesture-handler deprecates - * them in favour of Pressable. TouchableNativeFeedback is additionally gesture-handler's - * own only on Android; elsewhere it re-exports React Native's, which the rewrite reaches. - * - DrawerLayout, Swipeable — no plain `style` prop, only containerStyle / - * childrenContainerStyle / drawerContainerStyle, so the target is a design decision. - * - DrawerLayoutAndroid, RefreshControl — components/index.cts re-exports these straight - * from react-native, so there is no styled twin for them to inherit from. + * - ScrollView, Switch, TextInput, FlatList, Text — `createNativeWrapper` forwards + * unclaimed props to a react-native primitive and Text renders one directly, so the + * rewrite already reaches these. Wrapping them would style the handler, not the view. + * `react-native-gesture-handler-rewrite.test.tsx` renders them under that rewrite. + * - The four touchables, DrawerLayout, Swipeable — className is dropped on all six. + * Gesture Handler marks every one `@deprecated`, in favour of Pressable and of the + * Reanimated twins. TouchableNativeFeedback is gesture-handler's own only on Android; + * elsewhere it re-exports react-native's, and that has no styled twin either. + * - RefreshControl — className is dropped, for the same missing-twin reason as + * DrawerLayoutAndroid. Left as-is because react-native's jest mock renders + * `` with no props at all, so a mapping here could not be tested, + * and `style` on a RefreshControl drives nothing on either platform. + * + * ReanimatedDrawerLayout and ReanimatedSwipeable are out of reach entirely: gesture-handler + * ships them as their own entry points rather than from its index, and the resolver branch + * matching this module is an exact `react-native-gesture-handler`. */ const pressableMapping: StyledConfiguration = { className: "style", @@ -95,3 +111,38 @@ export const BorderlessButton = copyComponentProperties( return useCssElement(RNGHBorderlessButton, props, borderlessButtonMapping); }, ); + +const pureNativeButtonMapping: StyledConfiguration< + typeof RNGHPureNativeButton +> = { + className: "style", +}; + +export const PureNativeButton = copyComponentProperties( + RNGHPureNativeButton, + (props: StyledProps) => { + return useCssElement(RNGHPureNativeButton, props, pureNativeButtonMapping); + }, +); + +const drawerLayoutAndroidMapping: StyledConfiguration< + typeof RNGHDrawerLayoutAndroid +> = { + className: "style", +}; + +export const DrawerLayoutAndroid = copyComponentProperties( + RNGHDrawerLayoutAndroid, + ( + props: StyledProps< + ComponentProps, + typeof drawerLayoutAndroidMapping + >, + ) => { + return useCssElement( + RNGHDrawerLayoutAndroid, + props, + drawerLayoutAndroidMapping, + ); + }, +); From dac729a6f5ebfb55fb56c323aeb020140c3e0d83 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:50:39 +0300 Subject: [PATCH 6/7] test: give both fixes a compiler plane, and measure the exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/_gesture-handler.ts | 148 +++++++++++ src/__tests__/compiler/important.test.tsx | 96 ++++++++ .../react-native-gesture-handler.test.tsx | 129 ++++++++++ .../native/className-with-style.test.tsx | 43 +++- ...ct-native-gesture-handler-rewrite.test.tsx | 147 +++++++---- .../react-native-gesture-handler.test.tsx | 231 +++++++++--------- 6 files changed, 627 insertions(+), 167 deletions(-) create mode 100644 src/__tests__/_gesture-handler.ts create mode 100644 src/__tests__/compiler/important.test.tsx create mode 100644 src/__tests__/compiler/react-native-gesture-handler.test.tsx diff --git a/src/__tests__/_gesture-handler.ts b/src/__tests__/_gesture-handler.ts new file mode 100644 index 00000000..7b5a10bf --- /dev/null +++ b/src/__tests__/_gesture-handler.ts @@ -0,0 +1,148 @@ +/** + * Shared by the three gesture-handler suites — the two native ones and the compiler + * one. Nothing here imports `react-native` or `react-native-gesture-handler` at module + * scope: the rewrite suite mocks `react-native`, so a module-scope import would resolve + * gesture-handler's own imports through the mock and quietly change what the census + * means. Each suite hands its module objects in instead. + */ + +interface RenderedNode { + props: Record; + children: RenderedNode[] | null; +} + +/** Every rendered element's props, at any depth. */ +export function collectProps(node: unknown): Record[] { + if (node === null || typeof node !== "object") { + return []; + } + + if (Array.isArray(node)) { + return node.flatMap((child) => collectProps(child)); + } + + const { props, children } = node as RenderedNode; + + return [props, ...collectProps(children)]; +} + +/** + * Every style object in the tree, at any array depth. The merge nests — a Pressable + * carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a + * single level would report an absence that is really a depth. + */ +export function flattenStyles(node: unknown): Record[] { + const collect = (style: unknown): Record[] => { + if (Array.isArray(style)) { + return style.flatMap((entry) => collect(entry)); + } + + return style !== null && typeof style === "object" + ? [style as Record] + : []; + }; + + return collectProps(node).flatMap((props) => collect(props.style)); +} + +/** + * Derived from the module, not restated: a member the wrapper re-declares is one whose + * export is no longer the one `export *` provided. Every generated case reads this, so + * an eighth re-declaration is covered the moment it lands. + */ +export function deriveReDeclared( + styledExports: Record, + gestureHandlerExports: Record, +): string[] { + return Object.keys(styledExports) + .filter((name) => styledExports[name] !== gestureHandlerExports[name]) + .sort(); +} + +/** + * The exclusion register, executable. Every gesture-handler export sits in exactly one + * of these groups or in the derived re-declared set, so a member can be neither + * re-declared nor excluded only by failing the accounting test — which is how + * `PureNativeButton`, a sixth member of the button family, went unnoticed. + */ +export const notAComponent = [ + "Directions", + "Gesture", + "GestureDetector", + "GestureHandlerRootView", + "HoverEffect", + "MouseButton", + "PointerType", + "State", + "createNativeWrapper", + "enableExperimentalWebImplementation", + "enableLegacyWebImplementation", + "gestureHandlerRootHOC", +]; + +/** Handlers wrap a child; they render no view of their own for a style to land on. */ +export const gestureHandlers = [ + "FlingGestureHandler", + "ForceTouchGestureHandler", + "LongPressGestureHandler", + "NativeViewGestureHandler", + "PanGestureHandler", + "PinchGestureHandler", + "RotationGestureHandler", + "TapGestureHandler", +]; + +/** Reached by the `react-native` rewrite already — see the rewrite suite. */ +export const reachedByTheRewrite = [ + "FlatList", + "ScrollView", + "Switch", + "Text", + "TextInput", +]; + +/** className is dropped, and gesture-handler marks every one `@deprecated`. */ +export const deprecatedByGestureHandler = [ + "DrawerLayout", + "Swipeable", + "TouchableHighlight", + "TouchableNativeFeedback", + "TouchableOpacity", + "TouchableWithoutFeedback", +]; + +/** className is dropped, and no test at this tier can observe a fix. */ +export const unobservable = ["RefreshControl"]; + +/** Every name the register gives a reason to, across the four component buckets. */ +export const reasonedExclusions = [ + ...gestureHandlers, + ...reachedByTheRewrite, + ...deprecatedByGestureHandler, + ...unobservable, +]; + +/** + * Derived, not the union of the buckets above: every export that renders and is not + * re-declared, whether or not anybody wrote a reason for it. The two differ exactly + * when a member has been missed, and the drop invariant is generated from THIS — so + * an unhandled component is rendered and held to the invariant rather than waiting + * for the accounting test to notice a name is absent from a list. + */ +export function deriveExcludedComponents( + gestureHandlerExports: Record, + reDeclared: string[], +): string[] { + return Object.keys(gestureHandlerExports) + .filter( + (name) => !reDeclared.includes(name) && !notAComponent.includes(name), + ) + .sort(); +} + +/** Props a component will not render at all without. */ +export const requiredProps: Record> = { + DrawerLayout: { renderNavigationView: () => null }, + DrawerLayoutAndroid: { renderNavigationView: () => null }, + FlatList: { data: [], renderItem: () => null }, +}; diff --git a/src/__tests__/compiler/important.test.tsx b/src/__tests__/compiler/important.test.tsx new file mode 100644 index 00000000..ccfff871 --- /dev/null +++ b/src/__tests__/compiler/important.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { Pressable } from "react-native-css/components/Pressable"; +import { registerCSS, testID } from "react-native-css/jest"; +import { Specificity } from "react-native-css/utilities"; + +/** + * `!important` is decided in the compiler and carried in the rule's specificity array, + * and the runtime reads that slot to choose which of its two merge passes a class takes. + * That makes the marker the compiler-plane half of a runtime defect: a `style` callback + * merged as data stops being a callback, and the two passes are separate code paths, so + * a guard on one says nothing about the other. The tests below pin the marker, then pin + * that a callback survives whichever pass the marker selects. + */ + +const PLAIN = `.bg-red { background-color: red; }`; +const IMPORTANT = `.bg-red\\! { background-color: red !important; }`; + +function ruleFor(css: string) { + const rule = compile(css).stylesheet().s?.[0]?.[1]?.[0]; + + if (rule === undefined) { + throw new Error(`compiled no rule for ${css}`); + } + + return rule; +} + +test("!important sets the Important slot, and nothing else moves", () => { + const plain = ruleFor(PLAIN); + const important = ruleFor(IMPORTANT); + + // Read through the exported census rather than the literal index — the slot is + // named `Specificity.Important`, and a reshuffle of that enum has to move this + // assertion with it rather than leave it pointing at a neighbour. + expect(plain.s[Specificity.Important]).toBeUndefined(); + expect(important.s[Specificity.Important]).toBe(1); + + // The declaration itself is untouched: the marker is the only difference, which + // is what makes it the thing that selects the route. + expect(plain.d).toStrictEqual(important.d); + expect(plain.s[Specificity.ClassName]).toBe( + important.s[Specificity.ClassName], + ); +}); + +test("the marker selects the merge pass, and the operand order is how you see it", () => { + registerCSS(`${PLAIN}\n${IMPORTANT}`); + + // Without the marker the class merges as the inline pass: the class value goes + // first and the callback's result second, so the callback wins on a conflict. + expect( + render( + ({ backgroundColor: "blue" })} + />, + ).getByTestId(testID).props.style, + ).toStrictEqual([{ backgroundColor: "#f00" }, { backgroundColor: "blue" }]); + + // Reversed with the marker: the important declaration is rightmost and wins. + expect( + render( + ({ backgroundColor: "blue" })} + />, + ).getByTestId(testID).props.style, + ).toStrictEqual([{ backgroundColor: "blue" }, { backgroundColor: "#f00" }]); +}); + +test("the callback ran on both routes rather than reaching the view unevaluated", () => { + registerCSS(`${PLAIN}\n${IMPORTANT}`); + + // `Pressable` picks its branch with `typeof style === "function"`. Merged into an + // array the answer is "object", the callback never runs, and the raw function + // reaches the native view — the entry below would be `[Function style]` instead of + // the object it returned, and every pressed-state style would be silently gone. + for (const className of ["bg-red", "bg-red!"]) { + const style = render( + ({ opacity: pressed ? 0.5 : 1 })} + />, + ).getByTestId(testID).props.style as unknown[]; + + expect(style).toContainEqual({ opacity: 1 }); + + for (const entry of style) { + expect(typeof entry).not.toBe("function"); + } + } +}); diff --git a/src/__tests__/compiler/react-native-gesture-handler.test.tsx b/src/__tests__/compiler/react-native-gesture-handler.test.tsx new file mode 100644 index 00000000..b43d6d94 --- /dev/null +++ b/src/__tests__/compiler/react-native-gesture-handler.test.tsx @@ -0,0 +1,129 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import * as StyledRNGH from "react-native-css/components/react-native-gesture-handler"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import * as RNGH from "react-native-gesture-handler"; + +import { + deriveReDeclared, + flattenStyles, + requiredProps, +} from "../_gesture-handler"; + +/** + * The compiler plane's statement about the gesture-handler defect is that it has none: + * `compile` takes a CSS string and nothing else, so no artifact it emits can know which + * component will consume it. That is a measurement rather than a reading of the + * signature — the file below compiles ONE declaration and drives it into a react-native + * primitive and into every re-declared gesture-handler component, and the compiled bytes + * are asserted beside both. Break the interop and the render halves go red while the + * compile assertion stays green; that insensitivity is the proof the fix does not belong + * on this plane. + */ + +const styledExports = StyledRNGH as unknown as Record; +const gestureHandlerExports = RNGH as unknown as Record; + +const reDeclared = deriveReDeclared(styledExports, gestureHandlerExports); + +const CSS = `.w-13 { width: 13px; }`; + +/** The one artifact every consumer below is driven from. */ +const ARTIFACT = { + s: [["w-13", [{ s: [1, 1], d: [{ width: 13 }] }]]], +}; + +function renderWith( + component: unknown, + props: Record, +): unknown { + const Component = component as ComponentType>; + + return render().toJSON(); +} + +test("the artifact is a function of the CSS alone", () => { + expect(compile(CSS).stylesheet()).toStrictEqual(ARTIFACT); + + // `registerCSS` is the same compile, so the runtime halves below read these bytes. + expect(registerCSS(CSS).stylesheet()).toStrictEqual(ARTIFACT); +}); + +test("the declaration carries a style object, not a prop target", () => { + // `@nativeMapping` is the compiler's own way to retarget a declaration onto some + // other prop, and it shows up in `d` as a value/path pair. The gesture-handler + // interop uses none of it: the emitted declaration is the ordinary style object, + // identical to the one `components/View.tsx` consumes. So the wrapper is a runtime + // mapping over an unremarkable artifact, and a reader looking for a compiler + // feature behind it will not find one. + const retargeted = compile( + `.w-13 { @nativeMapping myTarget; width: 13px; }`, + ).stylesheet().s?.[0]?.[1]; + + expect(compile(CSS).stylesheet().s?.[0]?.[1]).toStrictEqual([ + { s: [1, 1], d: [{ width: 13 }] }, + ]); + expect(retargeted).toStrictEqual([{ s: [1, 1], d: [[13, ["myTarget"]]] }]); +}); + +test("the census the two runtime halves are generated from is non-empty", () => { + // Derived by diffing the wrapper's exports against the real package's, so an + // eighth re-declaration joins both halves on its own. An emptied census would + // make every case below vacuous. + expect(reDeclared.length).toBeGreaterThan(0); +}); + +type Consumer = [ + label: string, + component: unknown, + extra: Record, +]; + +/** One react-native primitive, then every gesture-handler member the wrapper re-declares. */ +const consumers: Consumer[] = [ + ["react-native View", View, {}], + ...reDeclared.map((name) => [ + `gesture-handler ${name}`, + styledExports[name], + requiredProps[name] ?? {}, + ]), +]; + +describe.each(consumers)("%s", (_label, component, extra) => { + test("resolves the one artifact into a rendered style", () => { + registerCSS(CSS); + + expect( + flattenStyles(renderWith(component, { className: "w-13", ...extra })), + ).toContainEqual(expect.objectContaining({ width: 13 })); + }); +}); + +describe.each(reDeclared)("%s", (name) => { + test("the unwrapped twin leaves the same artifact unresolved", () => { + // The pair is what makes the claim discriminating: one compiled declaration, + // two components, opposite outcomes. Nothing the compiler emitted differs + // between them, so the divergence is entirely the component substitution. + registerCSS(CSS); + + const extra = requiredProps[name] ?? {}; + + expect( + flattenStyles( + renderWith(styledExports[name], { className: "w-13", ...extra }), + ), + ).toContainEqual(expect.objectContaining({ width: 13 })); + + expect( + flattenStyles( + renderWith(gestureHandlerExports[name], { + className: "w-13", + ...extra, + }), + ), + ).not.toContainEqual(expect.objectContaining({ width: 13 })); + }); +}); diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index fc5c20af..8ed6f40d 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -1,4 +1,4 @@ -import { View as RNView } from "react-native"; +import { Pressable as RNPressable, View as RNView } from "react-native"; import { render } from "@testing-library/react-native"; import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; @@ -121,6 +121,47 @@ describe("a callback style prop stays a callback", () => { expect(component.props.style).toStrictEqual({ opacity: 1 }); }); + + /** + * Both cases above reach `deepMergeConfig` through an ARRAY target, `["style"]`. + * A `styled()` mapping may also name its target as a bare string, and that is a + * separate branch of the same function — it never enters the array handling at + * all — so a guard on the array path holds nothing for it. The mapping below is + * the string form of the one `components/Pressable.tsx` carries, over the same + * component, so the only variable between this case and the first one is which + * branch of the merge the target shape selects. + */ + test("styled() with a string target: a callback stays a callback", () => { + registerCSS(`.text-red { color: red; }`); + + const mapping: StyledConfiguration = { + className: { target: "style" }, + }; + const StyledPressable = copyComponentProperties( + RNPressable, + ( + props: StyledProps< + React.ComponentProps, + typeof mapping + >, + ) => { + return useCssElement(RNPressable, props, mapping); + }, + ); + + const component = render( + ({ opacity: pressed ? 0.5 : 1 })} + />, + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual([ + { color: "#f00" }, + { opacity: 1 }, + ]); + }); }); test("View with multiple className properties where inline style takes precedence", () => { diff --git a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx index 33282a7d..116acd02 100644 --- a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx @@ -3,6 +3,14 @@ import type { ComponentType } from "react"; import { render } from "@testing-library/react-native"; import { registerCSS, testID } from "react-native-css/jest"; +import { + collectProps, + deprecatedByGestureHandler, + flattenStyles, + reachedByTheRewrite, + requiredProps, +} from "../_gesture-handler"; + /** * `nativeResolver` rewrites every `react-native` import outside this package to * `react-native-css/components` — react-native's own exports with the styled @@ -31,74 +39,42 @@ jest.mock("react-native", (): Record => { } }); -interface RenderedNode { - props: Record; - children: RenderedNode[] | null; -} - -function collectProps(node: unknown): Record[] { - if (node === null || typeof node !== "object") { - return []; - } - - if (Array.isArray(node)) { - return node.flatMap((child) => collectProps(child)); - } - - const { props, children } = node as RenderedNode; - - return [props, ...collectProps(children)]; -} - -function flattenStyles(node: unknown): Record[] { - const collect = (style: unknown): Record[] => { - if (Array.isArray(style)) { - return style.flatMap((entry) => collect(entry)); - } - - return style !== null && typeof style === "object" - ? [style as Record] - : []; - }; - - return collectProps(node).flatMap((props) => collect(props.style)); -} - function styledGestureHandler(): Record { return jest.requireActual>( "react-native-css/components/react-native-gesture-handler", ); } +function rewrittenReactNative(): Record { + return jest.requireMock>("react-native"); +} + +function realReactNative(): Record { + return jest.requireActual>("react-native"); +} + test("the rewrite is in effect", () => { // Every assertion below is vacuous if `react-native` resolves to itself here, // and the whole file would pass while measuring nothing. - const rewritten = jest.requireMock>("react-native"); - const real = jest.requireActual>("react-native"); - - expect(rewritten.View).not.toBe(real.View); - expect(rewritten.Dimensions).toBe(real.Dimensions); + expect(rewrittenReactNative().View).not.toBe(realReactNative().View); + expect(rewrittenReactNative().Dimensions).toBe(realReactNative().Dimensions); }); -describe.each([ - ["ScrollView", 31, {}], - ["Switch", 32, {}], - ["TextInput", 33, {}], - ["FlatList", 34, { data: [], renderItem: () => null }], - ["Text", 35, {}], -])("%s", (name, width, extra: Record) => { +describe.each( + reachedByTheRewrite.map((name) => [name, requiredProps[name] ?? {}] as const), +)("%s", (name, extra) => { test("resolves className through the rewrite, so it needs no re-declaration", () => { - registerCSS(`.w-${width} { width: ${width}px; }`); + registerCSS(`.w-51 { width: 51px; }`); const Component = styledGestureHandler()[name] as ComponentType< Record >; const tree = render( - , + , ).toJSON(); expect(flattenStyles(tree)).toContainEqual( - expect.objectContaining({ width }), + expect.objectContaining({ width: 51 }), ); for (const props of collectProps(tree)) { @@ -121,3 +97,78 @@ test("ScrollView's contentContainerClassName survives the rewrite too", () => { collectProps(tree).map((props) => props.contentContainerStyle), ).toContainEqual({ width: 52 }); }); + +/** + * The rewrite substitutes `react-native-css/components` for `react-native`, but that + * module layers a styled twin over only SOME of react-native's exports and re-exports + * the rest untouched (`components/index.cts`). So "the rewrite reaches it" and "the + * class survives" are different claims, and the register once conflated them: + * `TouchableNativeFeedback` was excluded as gesture-handler's own only on Android, + * "elsewhere it re-exports React Native's, which the rewrite reaches". The rewrite + * does reach the specifier — and hands back the identical unstyled component, so the + * class is dropped on every platform. + */ +const WITH_A_STYLED_TWIN = [ + "View", + "Text", + "ScrollView", + "TouchableOpacity", + "TouchableHighlight", +]; +const PASSED_THROUGH_UNSTYLED = [ + "TouchableNativeFeedback", + "DrawerLayoutAndroid", +]; + +describe("a rewritten name only carries className if it has a styled twin", () => { + function hasStyledTwin(name: string): boolean { + return rewrittenReactNative()[name] !== realReactNative()[name]; + } + + test("both verdicts are reachable, so neither group is asserting a constant", () => { + // A full walk of react-native's exports is not available to derive this from — + // reading `DevMenu` and friends calls `TurboModuleRegistry.getEnforcing` and + // throws outside a native binary. Naming both groups and requiring each to be + // non-empty is what keeps the two `test.each` blocks from becoming zero cases. + expect(WITH_A_STYLED_TWIN.length).toBeGreaterThan(0); + expect(PASSED_THROUGH_UNSTYLED.length).toBeGreaterThan(0); + }); + + test.each(WITH_A_STYLED_TWIN)("%s has a styled twin", (name) => { + expect(hasStyledTwin(name)).toBe(true); + }); + + test.each(PASSED_THROUGH_UNSTYLED)( + "%s is passed through unstyled — the register's missing twin", + (name) => { + expect(hasStyledTwin(name)).toBe(false); + }, + ); +}); + +test("TouchableNativeFeedback renders nothing here, so identity is the only reading", () => { + // React Native's own component is Android-only and returns null on this platform, + // so an assertion that no style reached the tree would be an absence over an empty + // set — it would pass with a misspelled class or a broken registerCSS. The claim + // that discriminates is the identity above; this pins why. + registerCSS(`.w-53 { width: 53px; }`); + + const TouchableNativeFeedback = styledGestureHandler() + .TouchableNativeFeedback as ComponentType>; + + expect( + render( + + <> + , + ).toJSON(), + ).toBeNull(); +}); + +test("the deprecated bucket is still deprecated under the rewrite", () => { + // The register's ground for these six is `@deprecated`, which the rewrite cannot + // change. Asserting the bucket is non-empty keeps the sibling suite's generated + // cases from silently becoming zero. + expect(deprecatedByGestureHandler).toContain("TouchableNativeFeedback"); + expect(deprecatedByGestureHandler.length).toBeGreaterThan(0); +}); diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx index 982ff7a8..a23ce84a 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -1,120 +1,33 @@ import type { ComponentType } from "react"; +import { DrawerLayoutAndroid as RNDrawerLayoutAndroid } from "react-native"; import { render } from "@testing-library/react-native"; import * as StyledRNGH from "react-native-css/components/react-native-gesture-handler"; import { registerCSS, testID } from "react-native-css/jest"; import * as RNGH from "react-native-gesture-handler"; -interface RenderedNode { - props: Record; - children: RenderedNode[] | null; -} - -function collectProps(node: unknown): Record[] { - if (node === null || typeof node !== "object") { - return []; - } - - if (Array.isArray(node)) { - return node.flatMap((child) => collectProps(child)); - } - - const { props, children } = node as RenderedNode; - - return [props, ...collectProps(children)]; -} - -/** - * Every style object in the tree, at any array depth. The merge nests — a Pressable - * carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a - * single level would report an absence that is really a depth. - */ -function flattenStyles(node: unknown): Record[] { - const collect = (style: unknown): Record[] => { - if (Array.isArray(style)) { - return style.flatMap((entry) => collect(entry)); - } - - return style !== null && typeof style === "object" - ? [style as Record] - : []; - }; - - return collectProps(node).flatMap((props) => collect(props.style)); -} +import { + collectProps, + deprecatedByGestureHandler, + deriveExcludedComponents, + deriveReDeclared, + flattenStyles, + gestureHandlers, + notAComponent, + reachedByTheRewrite, + reasonedExclusions, + requiredProps, + unobservable, +} from "../_gesture-handler"; const styledExports = StyledRNGH as unknown as Record; const gestureHandlerExports = RNGH as unknown as Record; -/** - * Derived from the module, not restated: a member this wrapper re-declares is one whose - * export is no longer the one `export *` would have provided. Every case below is - * generated from this, so a sixth re-declaration is covered the moment it lands. - */ -const reDeclared: string[] = Object.keys(styledExports) - .filter((name) => styledExports[name] !== gestureHandlerExports[name]) - .sort(); - -/** - * The exclusion register, executable. Every remaining gesture-handler export sits in one - * of these groups with its reason, so a member can be neither re-declared nor excluded - * only by failing the accounting test below — which is how `PureNativeButton`, a sixth - * member of the button family, went unnoticed. - */ -const notAComponent = [ - "Directions", - "Gesture", - "GestureDetector", - "GestureHandlerRootView", - "HoverEffect", - "MouseButton", - "PointerType", - "State", - "createNativeWrapper", - "enableExperimentalWebImplementation", - "enableLegacyWebImplementation", - "gestureHandlerRootHOC", -]; - -/** Handlers wrap a child; they render no view of their own for a style to land on. */ -const gestureHandlers = [ - "FlingGestureHandler", - "ForceTouchGestureHandler", - "LongPressGestureHandler", - "NativeViewGestureHandler", - "PanGestureHandler", - "PinchGestureHandler", - "RotationGestureHandler", - "TapGestureHandler", -]; - -/** Reached by the `react-native` rewrite already — see the rewrite test alongside this. */ -const reachedByTheRewrite = [ - "FlatList", - "ScrollView", - "Switch", - "Text", - "TextInput", -]; - -/** className is dropped, and gesture-handler marks every one `@deprecated`. */ -const deprecatedByGestureHandler = [ - "DrawerLayout", - "Swipeable", - "TouchableHighlight", - "TouchableNativeFeedback", - "TouchableOpacity", - "TouchableWithoutFeedback", -]; - -/** className is dropped, and no test at this tier can observe a fix — see below. */ -const unobservable = ["RefreshControl"]; - -/** Props a component will not render at all without. */ -const requiredProps: Record> = { - DrawerLayout: { renderNavigationView: () => null }, - DrawerLayoutAndroid: { renderNavigationView: () => null }, -}; +const reDeclared = deriveReDeclared(styledExports, gestureHandlerExports); +const excludedComponents = deriveExcludedComponents( + gestureHandlerExports, + reDeclared, +); /** Each component gets a width no other test uses, so "the style reached the tree" is exact. */ const cases: [name: string, width: number][] = reDeclared.map((name, index) => [ @@ -135,10 +48,7 @@ test("every gesture-handler export is either re-declared or excluded with a reas const accounted = new Set([ ...reDeclared, ...notAComponent, - ...gestureHandlers, - ...reachedByTheRewrite, - ...deprecatedByGestureHandler, - ...unobservable, + ...reasonedExclusions, ]); expect( @@ -255,17 +165,61 @@ test("resolves a function style beside className on Pressable", () => { }); test("re-exports the members it does not re-declare", () => { - for (const name of [ - ...notAComponent, - ...gestureHandlers, - ...reachedByTheRewrite, - ...deprecatedByGestureHandler, - ...unobservable, - ]) { + for (const name of [...notAComponent, ...reasonedExclusions]) { expect(styledExports[name]).toBe(gestureHandlerExports[name]); } }); +/** + * The register says `className` is DROPPED on the members it does not re-declare. + * Dropped and leaked are different failures and only one of them is what the register + * claims: `PureNativeButton` — the sixth member of the button family, absent from the + * first five by omission — rendered `{"type":"RNGestureHandlerButton","props": + * {"className":"pnb"}}`, putting the raw class string on a codegen'd native view. + * + * The census is derived from the module rather than from the reason buckets, so a + * seventh omission is rendered and held to the invariant here on the commit that + * introduces it, without anybody having to notice a name is missing from a list. + */ +const droppedWithoutTheRewrite = excludedComponents.filter( + (name) => !reachedByTheRewrite.includes(name), +); + +test("every census a describe.each reads is non-empty", () => { + // A narrowed export surface, or a bucket emptied in a refactor, would generate no + // cases at all and every block below would silently assert nothing. + expect(reDeclared.length).toBeGreaterThan(0); + expect(excludedComponents.length).toBeGreaterThan(0); + expect(droppedWithoutTheRewrite.length).toBeGreaterThan(0); + expect(reachedByTheRewrite.length).toBeGreaterThan(0); + expect(gestureHandlers.length).toBeGreaterThan(0); + expect(deprecatedByGestureHandler.length).toBeGreaterThan(0); + expect(unobservable.length).toBeGreaterThan(0); +}); + +describe.each(droppedWithoutTheRewrite)("%s", (name) => { + test("drops className rather than leaking it onto a rendered element", () => { + registerCSS(`.w-93 { width: 93px; }`); + + const Component = styledExports[name] as ComponentType< + Record + >; + const tree = render( + + child + , + ).toJSON(); + + for (const props of collectProps(tree)) { + expect(props).not.toHaveProperty("className"); + } + }); +}); + describe.each(deprecatedByGestureHandler)("%s", (name) => { const extra = requiredProps[name] ?? {}; @@ -273,7 +227,8 @@ describe.each(deprecatedByGestureHandler)("%s", (name) => { // Pins the exclusion register: these are not re-declared because gesture-handler // marks them `@deprecated`, NOT because the rewrite reaches them. It does not — // `components/index.cts` has no styled twin for `TouchableNativeFeedback`, and - // the other five are gesture-handler's own components. + // the other five are gesture-handler's own components. The rewrite suite pins + // that missing twin by object identity. registerCSS(`.w-91 { width: 91px; }`); const Component = styledExports[name] as ComponentType< @@ -291,6 +246,46 @@ describe.each(deprecatedByGestureHandler)("%s", (name) => { }); }); +/** + * The other half of the register, and the reason the rewrite suite is not a + * restatement of this one: WITHOUT the rewrite these five put the raw class string + * on the element, exactly as `PureNativeButton` did. They are excluded because the + * rewrite substitutes a styled react-native primitive underneath them, so what makes + * the exclusion true is a thing this file cannot see — measured here as the failure + * it becomes when that substitution is absent. + */ +describe.each(reachedByTheRewrite)("%s", (name) => { + test("leaks className without the rewrite, which is what the rewrite is for", () => { + registerCSS(`.w-94 { width: 94px; }`); + + const Component = styledExports[name] as ComponentType< + Record + >; + const tree = render( + , + ).toJSON(); + + expect( + collectProps(tree).filter((props) => Object.hasOwn(props, "className")), + ).not.toEqual([]); + }); +}); + +test("gesture-handler's DrawerLayoutAndroid is its own component, not a re-export", () => { + // The exclusion this replaces read "components/index.cts re-exports these straight + // from react-native, so there is no styled twin for them to inherit from" — which + // assumed gesture-handler hands back react-native's component. It wraps it in + // `createNativeWrapper` instead, so the rewrite never sees a `react-native` + // specifier here at all and the class was dropped on a component nothing reached. + expect(gestureHandlerExports.DrawerLayoutAndroid).not.toBe( + RNDrawerLayoutAndroid, + ); +}); + test("RefreshControl carries no props a test at this tier could read", () => { // React Native's own jest mock renders `` and drops every // prop, so no test here can watch a style reach it — react-native-css's own From 29bdd9b8d798f8a0377d6d2f5b57752683cddd31 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 10:37:41 +0300 Subject: [PATCH 7/7] fix: re-declare GestureHandlerRootView, and guard the handlers a byte diff drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GestureHandlerRootView` sat in the exclusion register's `notAComponent` bucket, which the generated invariants subtract, so nothing rendered it. It renders two ways. The default is ``, and the rewrite substitutes a styled twin for that `View`, so the class resolves. The Android one renders `specs/RNGestureHandlerRootViewNativeComponent` with no `react-native` specifier in the file, so the rewrite matches nothing: ANDROID/REWRITE {"type":"RNGestureHandlerRootView", "props":{"style":{"flex":1},"className":"w-80"}} That is the `PureNativeButton` shape on the export every app mounts at its root, on the platform where mounting it is mandatory. It type-checks today through `ViewProps`, so nothing stops a user writing it. A bare `className: "style"` mapping fixes Android and regresses the default: Gesture Handler reaches its `{ flex: 1 }` through `style ?? styles.container` over a module-private StyleSheet, and resolving a class is exactly a thing that makes `style` present. Measured, the class-only render went `[{width:95},{flex:1}]` to `[{width:95}]`. The wrapper carries the default itself and applies it on the same condition the `??` does — an inline style displaces it, a class does not. Jest's `defaultPlatform` is `ios`, so a suite cannot reach the Android variant by resolution. Substituting its module for the one the index requires puts the shipped wrapper over the shipped Android component; the new suite renders both variants and pins the byte-identical no-className path on each. Rendering the root view calls `maybeInitializeFabric`, which reaches an `install()` the JS module carries only inside a native binary, so the suites clear the global `isFabric()` reads. The no-className guard compares `JSON.stringify` of two trees, and that drops exactly the props whose value is a function — these nodes carry up to four. A wrapper written `const { onPress, ...rest } = props` reddened nothing: 3 failed, 1170 passed, byte-for-byte the control. `Object.keys().sort()` does not close it either, because Pressable renders `testOnly_onPress={props.onPress}` whether or not one was given, so only the value goes `undefined`. Neither does firing a press: `findEventHandler` walks `element.parent` until something carries a prop matching the event, and the JSX element the test itself wrote is on that path, so a component that drops `onPress` on the floor still answers with the caller's own callback. Comparing the function-valued prop names against the unwrapped twin is what sees it, and it is the exact complement of what the byte comparison sees. A test pins that a press is not a forwarding probe, so the closer that looks obvious is not written again. `DrawerLayoutAndroid` renders no handler at all under react-native's jest mock, which makes its generated case an equality between two empty sets. It is named rather than left to read as a measurement, and pinned beside the block. The Reanimated exclusion keeps its outcome and loses its reason. "Out of reach entirely" reads as an external constraint, and the constraint is this PR's own `moduleName ===` branch — a `startsWith` away, in a function that already carries a non-exact branch for react-native's Libraries. What actually blocks them is an API question: neither exposes a plain `style`, only `contentContainerStyle` / `drawerContainerStyle` and `containerStyle` / `childrenContainerStyle`, so covering them means minting four `*ClassName` props. Those are the same four `DrawerLayout` and `Swipeable` expose, which is what makes it worth revisiting: the deprecated bucket is excluded because Gesture Handler sends users to Pressable and to the Reanimated twins, and the twins are the uncovered set. The derivation's domain is `Object.keys` over the index module, so those two are outside it permanently — that is the limit on a census that otherwise enrols a new member on its own commit. --- src/__tests__/_gesture-handler.ts | 51 +++++- .../react-native-gesture-handler.test.tsx | 7 +- ...ct-native-gesture-handler-rewrite.test.tsx | 3 + ...-native-gesture-handler-root-view.test.tsx | 107 ++++++++++++ .../react-native-gesture-handler.test.tsx | 157 +++++++++++++++++- .../react-native-gesture-handler.native.tsx | 60 ++++++- 6 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/native/react-native-gesture-handler-root-view.test.tsx diff --git a/src/__tests__/_gesture-handler.ts b/src/__tests__/_gesture-handler.ts index 7b5a10bf..41cf8bd9 100644 --- a/src/__tests__/_gesture-handler.ts +++ b/src/__tests__/_gesture-handler.ts @@ -11,6 +11,18 @@ interface RenderedNode { children: RenderedNode[] | null; } +/** + * `GestureHandlerRootView` calls `maybeInitializeFabric()` while rendering, which + * reaches `RNGestureHandlerModule.install()` — a method the JS module carries only + * inside a native binary. React Native's own jest setup defines `nativeFabricUIManager` + * as `{}`, and `isFabric()` reads that global and nothing else, so clearing it takes + * the branch that never touches the module. Every suite that renders the re-declared + * census needs this, which is why it is here rather than in one of them. + */ +export function disableFabric(): void { + Reflect.set(globalThis, "nativeFabricUIManager", undefined); +} + /** Every rendered element's props, at any depth. */ export function collectProps(node: unknown): Record[] { if (node === null || typeof node !== "object") { @@ -26,6 +38,26 @@ export function collectProps(node: unknown): Record[] { return [props, ...collectProps(children)]; } +/** + * Every rendered element's function-valued prop names, at any depth. `JSON.stringify` + * drops exactly the props whose value is a function, so a byte comparison of two trees + * is blind to a wrapper that swallows a handler — and these nodes carry up to four. + * This is the complement of what the byte comparison sees, which is what makes the two + * together a whole guard. + * + * The value TYPE is the discriminator, not the name. Gesture Handler's Pressable renders + * `testOnly_onPress={props.onPress}` unconditionally, so the KEY is present either way + * and `Object.keys()` reports no difference at all; only the value goes `undefined`. + */ +export function functionPropNames(node: unknown): string[][] { + return collectProps(node).map((props) => + Object.entries(props) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name) + .sort(), + ); +} + /** * Every style object in the tree, at any array depth. The merge nests — a Pressable * carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a @@ -48,7 +80,7 @@ export function flattenStyles(node: unknown): Record[] { /** * Derived from the module, not restated: a member the wrapper re-declares is one whose * export is no longer the one `export *` provided. Every generated case reads this, so - * an eighth re-declaration is covered the moment it lands. + * a further re-declaration is covered the moment it lands. */ export function deriveReDeclared( styledExports: Record, @@ -69,7 +101,6 @@ export const notAComponent = [ "Directions", "Gesture", "GestureDetector", - "GestureHandlerRootView", "HoverEffect", "MouseButton", "PointerType", @@ -128,6 +159,11 @@ export const reasonedExclusions = [ * when a member has been missed, and the drop invariant is generated from THIS — so * an unhandled component is rendered and held to the invariant rather than waiting * for the accounting test to notice a name is absent from a list. + * + * The domain is `Object.keys` over the index module, and that is the limit of what + * deriving buys: ReanimatedDrawerLayout and ReanimatedSwipeable ship from their own + * entry points, so they are outside it permanently and no upstream change can enrol + * them here. Covering those two is an edit to this file, not a thing it notices. */ export function deriveExcludedComponents( gestureHandlerExports: Record, @@ -140,6 +176,17 @@ export function deriveExcludedComponents( .sort(); } +/** + * Re-declared members that render no function-valued prop for the guard to compare, so + * its verdict on them is an equality between two empty sets. React Native's jest mock for + * the Android-only DrawerLayoutAndroid renders a debug placeholder `View` and forwards + * none of its props — not `testID`, not a callback — which is the same tier limit the + * RefreshControl exclusion stands on. Naming it is what stops the generated case reading + * as a measurement it is not, and the pinned test beside the block is what keeps the + * name honest. + */ +export const handlerUnobservable = ["DrawerLayoutAndroid"]; + /** Props a component will not render at all without. */ export const requiredProps: Record> = { DrawerLayout: { renderNavigationView: () => null }, diff --git a/src/__tests__/compiler/react-native-gesture-handler.test.tsx b/src/__tests__/compiler/react-native-gesture-handler.test.tsx index b43d6d94..b6325a8b 100644 --- a/src/__tests__/compiler/react-native-gesture-handler.test.tsx +++ b/src/__tests__/compiler/react-native-gesture-handler.test.tsx @@ -9,10 +9,13 @@ import * as RNGH from "react-native-gesture-handler"; import { deriveReDeclared, + disableFabric, flattenStyles, requiredProps, } from "../_gesture-handler"; +beforeAll(disableFabric); + /** * The compiler plane's statement about the gesture-handler defect is that it has none: * `compile` takes a CSS string and nothing else, so no artifact it emits can know which @@ -70,8 +73,8 @@ test("the declaration carries a style object, not a prop target", () => { }); test("the census the two runtime halves are generated from is non-empty", () => { - // Derived by diffing the wrapper's exports against the real package's, so an - // eighth re-declaration joins both halves on its own. An emptied census would + // Derived by diffing the wrapper's exports against the real package's, so a + // further re-declaration joins both halves on its own. An emptied census would // make every case below vacuous. expect(reDeclared.length).toBeGreaterThan(0); }); diff --git a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx index 116acd02..0260ed7e 100644 --- a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx @@ -6,11 +6,14 @@ import { registerCSS, testID } from "react-native-css/jest"; import { collectProps, deprecatedByGestureHandler, + disableFabric, flattenStyles, reachedByTheRewrite, requiredProps, } from "../_gesture-handler"; +beforeAll(disableFabric); + /** * `nativeResolver` rewrites every `react-native` import outside this package to * `react-native-css/components` — react-native's own exports with the styled diff --git a/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx b/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx new file mode 100644 index 00000000..3e3d23c9 --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx @@ -0,0 +1,107 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { + collectProps, + disableFabric, + flattenStyles, +} from "../_gesture-handler"; + +/** + * `GestureHandlerRootView` has two implementations, and only one of them is reachable + * from the sibling suites. The default renders a react-native `View`, so the rewrite + * substitutes a styled twin underneath it and the class resolves whether or not this + * package re-declares anything. The Android one renders + * `specs/RNGestureHandlerRootViewNativeComponent` — a codegen'd native view, with no + * `react-native` specifier anywhere in the file — so the rewrite has nothing to match + * and the raw class string lands on the element. That is the `PureNativeButton` shape, + * on the export every app mounts at its root, on the platform where mounting it is + * mandatory. + * + * Jest's `defaultPlatform` is `ios`, so the sibling suites resolve the default variant + * and cannot see this. Substituting the Android module for the one the index requires + * is what puts the shipped wrapper over the shipped Android component. + */ +jest.mock( + "react-native-gesture-handler/lib/commonjs/components/GestureHandlerRootView", + (): Record => + jest.requireActual>( + "react-native-gesture-handler/lib/commonjs/components/GestureHandlerRootView.android", + ), +); + +beforeAll(disableFabric); + +function styledRootView(): ComponentType> { + return jest.requireActual>( + "react-native-css/components/react-native-gesture-handler", + ).GestureHandlerRootView as ComponentType>; +} + +function unwrappedRootView(): ComponentType> { + return jest.requireActual>( + "react-native-gesture-handler", + ).GestureHandlerRootView as ComponentType>; +} + +function renderWith( + component: ComponentType>, + props: Record = {}, +): unknown { + const Component = component; + + return render().toJSON(); +} + +test("the Android variant is what these cases render", () => { + // Without the substitution every assertion below measures the default variant, which + // the sibling suites already cover — and the two disagree precisely here. + expect(JSON.stringify(renderWith(unwrappedRootView()))).toContain( + "RNGestureHandlerRootView", + ); +}); + +test("the unwrapped Android variant leaks the class — the bug this closes", () => { + registerCSS(`.w-97 { width: 97px; }`); + + const tree = renderWith(unwrappedRootView(), { className: "w-97" }); + + expect( + collectProps(tree).filter((props) => Object.hasOwn(props, "className")), + ).not.toEqual([]); + expect(flattenStyles(tree)).not.toContainEqual( + expect.objectContaining({ width: 97 }), + ); +}); + +test("the wrapper resolves the class on Android, where the rewrite cannot", () => { + registerCSS(`.w-98 { width: 98px; }`); + + const tree = renderWith(styledRootView(), { className: "w-98" }); + + expect(flattenStyles(tree)).toContainEqual( + expect.objectContaining({ width: 98 }), + ); + + for (const props of collectProps(tree)) { + expect(props).not.toHaveProperty("className"); + } +}); + +test("the flex:1 default survives the class on Android too", () => { + registerCSS(`.w-99 { width: 99px; }`); + + expect( + flattenStyles(renderWith(styledRootView(), { className: "w-99" })), + ).toContainEqual(expect.objectContaining({ flex: 1 })); +}); + +test("renders identically to the unwrapped Android variant with no className", () => { + // The resolver routes every gesture-handler import in the graph through this module, + // so the no-className path has to stay byte-identical on both variants. + expect(JSON.stringify(renderWith(styledRootView()))).toBe( + JSON.stringify(renderWith(unwrappedRootView())), + ); +}); diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx index a23ce84a..1cfc1155 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -1,18 +1,22 @@ -import type { ComponentType } from "react"; +import type { ComponentType, ReactElement } from "react"; import { DrawerLayoutAndroid as RNDrawerLayoutAndroid } from "react-native"; -import { render } from "@testing-library/react-native"; +import { fireEvent, render } from "@testing-library/react-native"; import * as StyledRNGH from "react-native-css/components/react-native-gesture-handler"; import { registerCSS, testID } from "react-native-css/jest"; import * as RNGH from "react-native-gesture-handler"; +import type { PressableProps } from "react-native-gesture-handler"; import { collectProps, deprecatedByGestureHandler, deriveExcludedComponents, deriveReDeclared, + disableFabric, flattenStyles, + functionPropNames, gestureHandlers, + handlerUnobservable, notAComponent, reachedByTheRewrite, reasonedExclusions, @@ -20,6 +24,8 @@ import { unobservable, } from "../_gesture-handler"; +beforeAll(disableFabric); + const styledExports = StyledRNGH as unknown as Record; const gestureHandlerExports = RNGH as unknown as Record; @@ -61,6 +67,7 @@ test("every gesture-handler export is either re-declared or excluded with a reas "BaseButton", "BorderlessButton", "DrawerLayoutAndroid", + "GestureHandlerRootView", "Pressable", "PureNativeButton", "RawButton", @@ -139,6 +146,92 @@ describe.each(cases)("%s", (name, width) => { JSON.stringify(renderWith(gestureHandlerExports[name], extra)), ); }); + + test("forwards the same handlers as the unwrapped component", () => { + // The guard above compares serialized bytes, and `JSON.stringify` drops exactly the + // props whose value is a function — so a wrapper that destructured `onPress` out and + // forwarded the rest renders byte-identically and passes it. This is the complement: + // the two together see the whole prop set, and neither alone does. + const withHandler = { ...extra, onPress: () => undefined }; + + expect( + functionPropNames(renderWith(styledExports[name], withHandler)), + ).toEqual( + functionPropNames(renderWith(gestureHandlerExports[name], withHandler)), + ); + }); +}); + +/** + * The comparison above is an equality, so it is only a measurement where both sides have + * something in them. These are the members that render a handler at all. + */ +const handlerObservable = reDeclared.filter( + (name) => !handlerUnobservable.includes(name), +); + +test("every member the handler guard is a measurement on renders one", () => { + expect(handlerObservable.length).toBeGreaterThan(0); + expect(handlerUnobservable.length).toBeGreaterThan(0); + + for (const name of handlerObservable) { + expect( + functionPropNames( + renderWith(styledExports[name], { + ...(requiredProps[name] ?? {}), + onPress: () => undefined, + }), + ).flat(), + ).not.toEqual([]); + } +}); + +test("DrawerLayoutAndroid renders no handler, so its case is an empty equality", () => { + // Pins the one exclusion above. React Native's jest mock for the Android-only component + // renders a debug placeholder and drops every prop, testID included, so the generated + // comparison for it is `[] === []` and would pass over any wrapper at all. If the mock + // ever forwards props, this turns red and the exclusion is retaken. + expect( + functionPropNames( + renderWith(styledExports.DrawerLayoutAndroid, { + ...requiredProps.DrawerLayoutAndroid, + onPress: () => undefined, + }), + ).flat(), + ).toEqual([]); +}); + +test("a press cannot stand in for the handler guard — it answers from the caller", () => { + // The obvious closer for a swallowed handler is to fire one, and it does not work. + // `fireEvent`'s `findEventHandler` walks `element.parent` until some element carries a + // prop matching the event, and the JSX element the test itself wrote is on that path — + // so a component that drops `onPress` on the floor still answers a press with the + // caller's own callback. The swallowing component below is the mutation this guard + // exists to catch, and the two assertions are the two verdicts on it. + let presses = 0; + + function SwallowsOnPress({ + onPress: _onPress, + ...rest + }: PressableProps & { onPress: () => void }): ReactElement { + return ; + } + + const view = render( + { + presses += 1; + }} + />, + ); + + fireEvent.press(view.getByTestId(testID)); + expect(presses).toBe(1); + + expect(functionPropNames(view.toJSON())).not.toEqual( + functionPropNames(renderWith(RNGH.Pressable, { onPress: () => undefined })), + ); }); test("resolves a function style beside className on Pressable", () => { @@ -164,6 +257,58 @@ test("resolves a function style beside className on Pressable", () => { } }); +/** + * `GestureHandlerRootView` renders `style={style ?? styles.container}` over a private + * `{ flex: 1 }`, so the fallback is reached only while `style` is absent — and a + * `className: "style"` mapping is exactly a thing that makes it present. A wrapper that + * only mapped the class would resolve the class and silently un-flex every root view + * that carries one, collapsing the app to its content's height. The wrapper carries the + * default itself for that reason, and these three pin the boundary the `??` draws: + * the class does not count as a style, an inline style does. + */ +describe("GestureHandlerRootView's flex:1 default", () => { + test("survives a className, which supplies a style where none was given", () => { + registerCSS(`.w-95 { width: 95px; }`); + + const styles = flattenStyles( + renderWith(StyledRNGH.GestureHandlerRootView, { className: "w-95" }), + ); + + expect(styles).toContainEqual(expect.objectContaining({ width: 95 })); + expect(styles).toContainEqual(expect.objectContaining({ flex: 1 })); + }); + + test("yields to an inline style, exactly as the unwrapped component does", () => { + // Not a defect being preserved out of caution: `??` is gesture-handler's own + // documented contract for the prop, and an interop wrapper that improved on it + // would make the styled root view behave unlike the one every other consumer + // in the graph renders. + const styles = flattenStyles( + renderWith(StyledRNGH.GestureHandlerRootView, { + style: { margin: 3 }, + }), + ); + + expect(styles).toContainEqual(expect.objectContaining({ margin: 3 })); + expect(styles).not.toContainEqual(expect.objectContaining({ flex: 1 })); + }); + + test("yields to an inline style given beside a className", () => { + registerCSS(`.w-96 { width: 96px; }`); + + const styles = flattenStyles( + renderWith(StyledRNGH.GestureHandlerRootView, { + className: "w-96", + style: { margin: 4 }, + }), + ); + + expect(styles).toContainEqual(expect.objectContaining({ width: 96 })); + expect(styles).toContainEqual(expect.objectContaining({ margin: 4 })); + expect(styles).not.toContainEqual(expect.objectContaining({ flex: 1 })); + }); +}); + test("re-exports the members it does not re-declare", () => { for (const name of [...notAComponent, ...reasonedExclusions]) { expect(styledExports[name]).toBe(gestureHandlerExports[name]); @@ -173,12 +318,12 @@ test("re-exports the members it does not re-declare", () => { /** * The register says `className` is DROPPED on the members it does not re-declare. * Dropped and leaked are different failures and only one of them is what the register - * claims: `PureNativeButton` — the sixth member of the button family, absent from the - * first five by omission — rendered `{"type":"RNGestureHandlerButton","props": - * {"className":"pnb"}}`, putting the raw class string on a codegen'd native view. + * claims: `PureNativeButton` — a member of the button family absent from the mappings + * by omission — renders `{"type":"RNGestureHandlerButton","props":{"className":"pnb"}}` + * unwrapped, putting the raw class string on a codegen'd native view. * * The census is derived from the module rather than from the reason buckets, so a - * seventh omission is rendered and held to the invariant here on the commit that + * further omission is rendered and held to the invariant here on the commit that * introduces it, without anybody having to notice a name is missing from a list. */ const droppedWithoutTheRewrite = excludedComponents.filter( diff --git a/src/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx index 57be2aaa..373c9202 100644 --- a/src/components/react-native-gesture-handler.native.tsx +++ b/src/components/react-native-gesture-handler.native.tsx @@ -1,4 +1,5 @@ import type { ComponentProps } from "react"; +import { StyleSheet } from "react-native"; import { useCssElement, @@ -9,6 +10,7 @@ import { BaseButton as RNGHBaseButton, BorderlessButton as RNGHBorderlessButton, DrawerLayoutAndroid as RNGHDrawerLayoutAndroid, + GestureHandlerRootView as RNGHGestureHandlerRootView, Pressable as RNGHPressable, PureNativeButton as RNGHPureNativeButton, RawButton as RNGHRawButton, @@ -34,6 +36,14 @@ export * from "react-native-gesture-handler"; * and the rewrite hands it react-native's raw component — `components/index.cts` has no * styled twin to inherit from — so it needs the mapping too. It forwards `style`. * + * GestureHandlerRootView has two implementations and the rewrite reaches only one. The + * default renders a react-native `View`, so a styled twin lands underneath it; the Android + * one renders `specs/RNGestureHandlerRootViewNativeComponent`, with no `react-native` + * specifier in the file for the rewrite to match, and the class string reaches a codegen'd + * native view — the PureNativeButton shape, on the export every app mounts at its root and + * on the platform where mounting it is mandatory. Both forward `style`, so one mapping + * covers both; `rootViewDefault` below is the part that is not just a mapping. + * * Not re-declared, and why: * * - ScrollView, Switch, TextInput, FlatList, Text — `createNativeWrapper` forwards @@ -49,9 +59,21 @@ export * from "react-native-gesture-handler"; * `` with no props at all, so a mapping here could not be tested, * and `style` on a RefreshControl drives nothing on either platform. * - * ReanimatedDrawerLayout and ReanimatedSwipeable are out of reach entirely: gesture-handler - * ships them as their own entry points rather than from its index, and the resolver branch - * matching this module is an exact `react-native-gesture-handler`. + * ReanimatedDrawerLayout and ReanimatedSwipeable are out of scope rather than out of reach. + * Gesture Handler ships them as their own entry points and names neither from its index, so + * `nativeResolver`'s exact `moduleName === "react-native-gesture-handler"` does not match + * them — but that exactness is a scoping choice made in a function that already carries a + * non-exact branch for react-native's own Libraries, and it is a `startsWith` from covering + * them. What holds them back is an API question, not a resolver one: neither exposes a plain + * `style`, only `contentContainerStyle` / `drawerContainerStyle` and `containerStyle` / + * `childrenContainerStyle`, so covering them means minting `*ClassName` props on the + * `contentContainerClassName` pattern for four targets nothing else in this package names. + * + * Those are the same four props DrawerLayout and Swipeable expose, which is what makes the + * exclusion worth revisiting rather than closed: the deprecated bucket is excluded on the + * grounds that Gesture Handler sends users to Pressable and to the Reanimated twins, and the + * Reanimated twins are the uncovered set. The deprecated pair and their replacements need one + * decision between them, and neither has it yet. */ const pressableMapping: StyledConfiguration = { className: "style", @@ -125,6 +147,38 @@ export const PureNativeButton = copyComponentProperties( }, ); +const gestureHandlerRootViewMapping: StyledConfiguration< + typeof RNGHGestureHandlerRootView +> = { + className: "style", +}; + +/** + * Gesture Handler's own `{ flex: 1 }`, restated because it reaches the root view through + * `style ?? styles.container` over a module-private StyleSheet. Resolving a class is + * exactly a thing that makes `style` present, so the wrapper has to supply the fallback + * the `??` no longer reaches — and it applies on the same condition Gesture Handler + * applies it: an inline style displaces it, a class does not. + */ +const rootViewDefault = StyleSheet.create({ container: { flex: 1 } }); + +export const GestureHandlerRootView = copyComponentProperties( + RNGHGestureHandlerRootView, + ({ + style, + ...props + }: StyledProps< + ComponentProps, + typeof gestureHandlerRootViewMapping + >) => { + return useCssElement( + RNGHGestureHandlerRootView, + { ...props, style: style ?? rootViewDefault.container }, + gestureHandlerRootViewMapping, + ); + }, +); + const drawerLayoutAndroidMapping: StyledConfiguration< typeof RNGHDrawerLayoutAndroid > = {