From 13327e61967dbbdde6578312fed15d4658d5fb79 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 01:54:53 +0300 Subject: [PATCH 1/2] feat(native): interop react-native-gesture-handler's Pressable and button family Gesture Handler's Pressable, its button family and PureNativeButton render GestureHandlerButton, a codegen'd native view the react-native rewrite never reaches, so className fell onto a view that declares no such prop. DrawerLayoutAndroid wraps react-native's component, which has no styled twin, and the Android GestureHandlerRootView renders a native view too. react-native-css/components/react-native-gesture-handler re-declares these with className mapped to the style each forwards, and the Metro resolver routes react-native-gesture-handler to it. GestureHandlerRootView keeps its flex: 1 default, which a resolved class would otherwise displace. The resolver anchors its own-package check on the layout segment, since it runs from src/metro under the source condition and from dist//metro once built; a fixed level count sent this package's own components back through the wrapper. Pressable calls a function style with its pressed state, so the className merge keeps a callback a callback on every merge path rather than merging it into an array, where it never runs. The inline branch of the style-target merge is removed: the length-one target merge after it always decides the inline style. --- package.json | 13 + src/__tests__/_gesture-handler.ts | 146 +++++++ src/__tests__/compiler/important.test.tsx | 78 ++++ .../react-native-gesture-handler.test.tsx | 111 ++++++ src/__tests__/metro/resolver.test.ts | 227 +++++++++++ .../native/className-with-style.test.tsx | 83 +++- ...ct-native-gesture-handler-rewrite.test.tsx | 145 +++++++ ...-native-gesture-handler-root-view.test.tsx | 90 +++++ .../react-native-gesture-handler.test.tsx | 368 ++++++++++++++++++ .../react-native-gesture-handler.native.tsx | 148 +++++++ .../react-native-gesture-handler.tsx | 1 + src/metro/resolver.ts | 17 +- src/native/styles/index.ts | 72 ++-- types.d.ts | 8 + yarn.lock | 57 +++ 15 files changed, 1516 insertions(+), 48 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 create mode 100644 src/__tests__/metro/resolver.test.ts create mode 100644 src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx create mode 100644 src/__tests__/native/react-native-gesture-handler-root-view.test.tsx 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 eee8f789..2c9bc615 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", @@ -271,6 +283,7 @@ "react": "19.2.3", "react-native": "0.86.3", "react-native-builder-bob": "^0.43.0", + "react-native-gesture-handler": "~2.32.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-worklets": "0.10.1", diff --git a/src/__tests__/_gesture-handler.ts b/src/__tests__/_gesture-handler.ts new file mode 100644 index 00000000..ceaeca47 --- /dev/null +++ b/src/__tests__/_gesture-handler.ts @@ -0,0 +1,146 @@ +// Nothing here imports react-native at module scope, since the rewrite suite mocks it + +interface RenderedNode { + props: Record; + children: RenderedNode[] | null; +} + +// GestureHandlerRootView installs a native module under Fabric, so the jest preset's nativeFabricUIManager is cleared +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") { + return []; + } + + if (Array.isArray(node)) { + return node.flatMap((child) => collectProps(child)); + } + + const { props, children } = node as RenderedNode; + + return [props, ...collectProps(children)]; +} + +// handlerTag counts handlers as they mount, so two renders of one tree differ only there +export function serializeRender(node: unknown): string { + return JSON.stringify(node, (key, value: unknown) => + key === "handlerTag" ? undefined : value, + ); +} + +// JSON.stringify drops function props, so handlers are compared by the value's type; Pressable renders testOnly_onPress whether or not one was passed +export function functionPropNames(node: unknown): string[][] { + return collectProps(node).map((props) => + Object.entries(props) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name) + .sort(), + ); +} + +// The merge nests style arrays, so styles are collected at every 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)); +} + +export function deriveReDeclared( + styledExports: Record, + gestureHandlerExports: Record, +): string[] { + return Object.keys(styledExports) + .filter((name) => styledExports[name] !== gestureHandlerExports[name]) + .sort(); +} + +// Every gesture-handler export is either re-declared or excluded here with its reason +export const notAComponent = [ + "Directions", + "Gesture", + "GestureDetector", + "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 from the module, so a component missing from every bucket is still rendered and checked +export function deriveExcludedComponents( + gestureHandlerExports: Record, + reDeclared: string[], +): string[] { + return Object.keys(gestureHandlerExports) + .filter( + (name) => !reDeclared.includes(name) && !notAComponent.includes(name), + ) + .sort(); +} + +// React Native's jest mock for DrawerLayoutAndroid renders a placeholder that forwards no props +export const handlerUnobservable = ["DrawerLayoutAndroid"]; + +/** 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..22c3e386 --- /dev/null +++ b/src/__tests__/compiler/important.test.tsx @@ -0,0 +1,78 @@ +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 selects which of the runtime's two merge passes a class takes, so a callback style is checked through both + +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); + + expect(plain.s[Specificity.Important]).toBeUndefined(); + expect(important.s[Specificity.Important]).toBe(1); + + 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}`); + + 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}`); + + 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..2f7a18c8 --- /dev/null +++ b/src/__tests__/compiler/react-native-gesture-handler.test.tsx @@ -0,0 +1,111 @@ +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, + disableFabric, + flattenStyles, + requiredProps, +} from "../_gesture-handler"; + +beforeAll(disableFabric); + +// The compiler emits the same declaration whichever component consumes it, so the interop is a runtime mapping + +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", () => { + 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", () => { + 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", () => { + 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__/metro/resolver.test.ts b/src/__tests__/metro/resolver.test.ts new file mode 100644 index 00000000..a4fdab05 --- /dev/null +++ b/src/__tests__/metro/resolver.test.ts @@ -0,0 +1,227 @@ +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"); + +function contextFor(originModulePath: string): CustomResolutionContext { + return { originModulePath } as unknown as CustomResolutionContext; +} + +interface Recorder { + readonly resolver: CustomResolver; + readonly calls: [moduleName: string, platform: string | null][]; +} + +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", + ); + + 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/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 8896f3b4..500ed3ed 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -1,8 +1,9 @@ -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"; 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,86 @@ test("important should overwrite the inline style", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); +describe("a callback style prop stays a callback", () => { + 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); + + 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 }); + }); + + // A string target takes a separate branch of the merge from the array target above + 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", () => { registerCSS(` .px-4 { padding-left: 16px; padding-right: 16px; } 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..0ec9bbca --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx @@ -0,0 +1,145 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { + collectProps, + deprecatedByGestureHandler, + disableFabric, + flattenStyles, + reachedByTheRewrite, + requiredProps, +} from "../_gesture-handler"; + +beforeAll(disableFabric); + +// The rewrite substitutes react-native-css/components for react-native everywhere but this package's own files +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; + } +}); + +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", () => { + expect(rewrittenReactNative().View).not.toBe(realReactNative().View); + expect(rewrittenReactNative().Dimensions).toBe(realReactNative().Dimensions); +}); + +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-51 { width: 51px; }`); + + const Component = styledGestureHandler()[name] as ComponentType< + Record + >; + const tree = render( + , + ).toJSON(); + + expect(flattenStyles(tree)).toContainEqual( + expect.objectContaining({ width: 51 }), + ); + + 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 }); +}); + +// react-native-css/components layers styled twins over only some react-native exports and re-exports the rest unstyled +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", () => { + // Reading every react-native export throws outside a native binary, so both groups are named and required to be non-empty + 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", () => { + 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", () => { + expect(deprecatedByGestureHandler).toContain("TouchableNativeFeedback"); + expect(deprecatedByGestureHandler.length).toBeGreaterThan(0); +}); 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..ff86db7b --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx @@ -0,0 +1,90 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { + collectProps, + disableFabric, + flattenStyles, + serializeRender, +} from "../_gesture-handler"; + +// Jest resolves the iOS GestureHandlerRootView, so the Android variant, a native view the rewrite cannot reach, is substituted +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", () => { + expect(serializeRender(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", () => { + expect(serializeRender(renderWith(styledRootView()))).toBe( + serializeRender(renderWith(unwrappedRootView())), + ); +}); 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..73615929 --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -0,0 +1,368 @@ +import type { ComponentType, ReactElement } from "react"; +import { DrawerLayoutAndroid as RNDrawerLayoutAndroid } from "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, + requiredProps, + serializeRender, + unobservable, +} from "../_gesture-handler"; + +beforeAll(disableFabric); + +const styledExports = StyledRNGH as unknown as Record; +const gestureHandlerExports = RNGH as unknown as Record; + +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) => [ + name, + 11 + index, +]); + +function renderWith( + component: unknown, + props: Record, +): unknown { + const Component = component as ComponentType>; + + return render().toJSON(); +} + +test("every gesture-handler export is either re-declared or excluded with a reason", () => { + const accounted = new Set([ + ...reDeclared, + ...notAComponent, + ...reasonedExclusions, + ]); + + expect( + Object.keys(gestureHandlerExports).filter((name) => !accounted.has(name)), + ).toEqual([]); + + expect(reDeclared).toEqual([ + "BaseButton", + "BorderlessButton", + "DrawerLayoutAndroid", + "GestureHandlerRootView", + "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 tree = renderWith(styledExports[name], { className, ...extra }); + + expect(flattenStyles(tree)).toContainEqual( + expect.objectContaining({ width }), + ); + }); + + test("never forwards className to a rendered element", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + const tree = renderWith(styledExports[name], { className, ...extra }); + + for (const props of collectProps(tree)) { + expect(props).not.toHaveProperty("className"); + } + }); + + test("the unwrapped component drops the declaration — the bug this closes", () => { + registerCSS(`.${className} { width: ${width}px; }`); + + expect( + flattenStyles(renderWith(styledExports[name], { className, ...extra })), + ).toContainEqual(expect.objectContaining({ width })); + + expect( + flattenStyles( + renderWith(gestureHandlerExports[name], { className, ...extra }), + ), + ).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, + }), + ); + + expect(styles).toContainEqual(expect.objectContaining({ width })); + expect(styles).toContainEqual(expect.objectContaining({ height })); + }); + + test("renders identically to the unwrapped component when no className is given", () => { + expect(serializeRender(renderWith(styledExports[name], extra))).toBe( + serializeRender(renderWith(gestureHandlerExports[name], extra)), + ); + }); + + test("forwards the same handlers as the unwrapped component", () => { + const withHandler = { ...extra, onPress: () => undefined }; + + expect( + functionPropNames(renderWith(styledExports[name], withHandler)), + ).toEqual( + functionPropNames(renderWith(gestureHandlerExports[name], withHandler)), + ); + }); +}); + +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", () => { + 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", () => { + // fireEvent walks up to the test's own element, so a swallowed onPress still answers a press + 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", () => { + registerCSS(`.w-46 { width: 46px; }`); + + 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"); + } +}); + +// GestureHandlerRootView falls back to flex: 1 only while style is absent, and a class must not count as a style +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", () => { + // The ?? is gesture-handler's own contract, which the styled root view keeps + 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]); + } +}); + +const droppedWithoutTheRewrite = excludedComponents.filter( + (name) => !reachedByTheRewrite.includes(name), +); + +test("every census a describe.each reads is non-empty", () => { + 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] ?? {}; + + test("drops className — left as-is because gesture-handler deprecates it", () => { + registerCSS(`.w-91 { width: 91px; }`); + + const Component = styledExports[name] as ComponentType< + Record + >; + const tree = render( + + child + , + ).toJSON(); + + expect(flattenStyles(tree)).not.toContainEqual( + expect.objectContaining({ width: 91 }), + ); + }); +}); + +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", () => { + expect(gestureHandlerExports.DrawerLayoutAndroid).not.toBe( + RNDrawerLayoutAndroid, + ); +}); + +test("RefreshControl carries no props a test at this tier could read", () => { + // React Native's jest mock renders RCTRefreshControl without props, so no test can watch a style reach it + registerCSS(`.w-92 { width: 92px; }`); + + const tree = renderWith(StyledRNGH.RefreshControl, { + className: "w-92", + refreshing: false, + }); + + expect(collectProps(tree)).toEqual([{}]); +}); 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..2c9e496a --- /dev/null +++ b/src/components/react-native-gesture-handler.native.tsx @@ -0,0 +1,148 @@ +import type { ComponentProps } from "react"; +import { StyleSheet } from "react-native"; + +import { + useCssElement, + type StyledConfiguration, + type StyledProps, +} from "react-native-css"; +import { + BaseButton as RNGHBaseButton, + BorderlessButton as RNGHBorderlessButton, + DrawerLayoutAndroid as RNGHDrawerLayoutAndroid, + GestureHandlerRootView as RNGHGestureHandlerRootView, + Pressable as RNGHPressable, + PureNativeButton as RNGHPureNativeButton, + 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"; + +// These render a native view the react-native rewrite never reaches, so each maps className to the style it forwards +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); + }, +); + +const pureNativeButtonMapping: StyledConfiguration< + typeof RNGHPureNativeButton +> = { + className: "style", +}; + +export const PureNativeButton = copyComponentProperties( + RNGHPureNativeButton, + (props: StyledProps) => { + return useCssElement(RNGHPureNativeButton, props, pureNativeButtonMapping); + }, +); + +const gestureHandlerRootViewMapping: StyledConfiguration< + typeof RNGHGestureHandlerRootView +> = { + className: "style", +}; + +// Gesture Handler applies its own flex: 1 through `style ?? styles.container`, which a resolved class would displace +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 +> = { + className: "style", +}; + +export const DrawerLayoutAndroid = copyComponentProperties( + RNGHDrawerLayoutAndroid, + ( + props: StyledProps< + ComponentProps, + typeof drawerLayoutAndroidMapping + >, + ) => { + return useCssElement( + RNGHDrawerLayoutAndroid, + props, + drawerLayoutAndroidMapping, + ); + }, +); 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..e1783b6f 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,13 @@ import type { import { allowedModules } from "../babel/allowedModules"; -const thisModuleDist = resolve(__dirname, "../../../dist"); -const thisModuleSrc = resolve(__dirname, "../../../src"); +// Anchored on the layout segment, since this runs from src/metro under the source condition and from dist//metro once built +const packageRoot = resolve( + __dirname, + basename(dirname(__dirname)) === "src" ? "../.." : "../../..", +); +const thisModuleDist = join(packageRoot, "dist"); +const thisModuleSrc = join(packageRoot, "src"); function isFromThisModule(filename: string): boolean { return ( @@ -41,6 +46,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/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..0f6a1161 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -356,6 +356,20 @@ function mergeDefinedProps( return result; } +type StyleCallback = (state: unknown) => unknown; + +function isStyleCallback(value: unknown): value is StyleCallback { + return typeof value === "function"; +} + +// Pressable calls a function style with its pressed state, so a merge keeps a callback a callback +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, @@ -382,50 +396,12 @@ function deepMergeConfig( if (left?.style) { result.style = left.style; } - } else if (rightIsInline && right?.style) { - // Filter inline styles if rightIsInline is true - const filteredRightStyle = filterCssVariables(right.style); - - if (left?.style) { - if (!filteredRightStyle) { - // All inline styles were CSS variables, only use left - result.style = left.style; - } else { - const leftStyle = left.style; - - // For arrays or objects, check if we need to create a style array - const leftIsObject = - typeof leftStyle === "object" && !Array.isArray(leftStyle); - const rightIsObject = - typeof filteredRightStyle === "object" && - !Array.isArray(filteredRightStyle); - - if (leftIsObject && rightIsObject) { - if (hasNonOverlappingProperties(leftStyle, filteredRightStyle)) { - result.style = [leftStyle, filteredRightStyle]; - } else { - // All left properties are in right, right overrides - result.style = filteredRightStyle; - } - } else { - // One or both are arrays, merge them - result.style = [leftStyle, filteredRightStyle]; - } - } - } else { - // No left style, just use filtered right - if (filteredRightStyle) { - result.style = filteredRightStyle; - } else { - // All filtered out, remove style prop - delete result.style; - } - } } 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 ( @@ -503,7 +479,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 { @@ -536,8 +514,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; diff --git a/types.d.ts b/types.d.ts index 924b9a7d..b558fba6 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,6 +15,14 @@ declare module "@react-native/virtualized-lists" { } } +declare module "react-native-gesture-handler" { + // The button family extends neither ViewProps nor TouchableWithoutFeedbackProps + 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 ced6f69e..d812a89e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2576,6 +2576,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" @@ -4785,6 +4794,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" @@ -4877,6 +4893,15 @@ __metadata: languageName: node linkType: hard +"@types/react-test-renderer@npm:^19.1.0": + version: 19.3.0 + resolution: "@types/react-test-renderer@npm:19.3.0" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/37f5588f698a057a33c1f4d5fd28a042f3df7f29a3554ddebb7f9c8ecd3e5fba085e5b12d14b30734c30b51959ef17544650b2a9704d629d0af251f126d8ae73 + languageName: node + linkType: hard + "@types/react@npm:*": version: 19.1.13 resolution: "@types/react@npm:19.1.13" @@ -8451,6 +8476,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" @@ -12085,6 +12119,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" @@ -12201,6 +12242,7 @@ __metadata: react: "npm:19.2.3" react-native: "npm:0.86.3" react-native-builder-bob: "npm:^0.43.0" + react-native-gesture-handler: "npm:~2.32.0" react-native-reanimated: "npm:4.5.1" react-native-safe-area-context: "npm:~5.7.0" react-native-worklets: "npm:0.10.1" @@ -12219,6 +12261,21 @@ __metadata: languageName: unknown linkType: soft +"react-native-gesture-handler@npm:~2.32.0": + version: 2.32.0 + resolution: "react-native-gesture-handler@npm:2.32.0" + dependencies: + "@egjs/hammerjs": "npm:^2.0.17" + "@types/react-test-renderer": "npm:^19.1.0" + hoist-non-react-statics: "npm:^3.3.0" + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/f3d903a8ad1f053732f7cbfbd16891a6bc696f4d03428a334948587a203fdcfa29592f1fc6c4c03c175932febe9df1fce3eb40ddd54d2fa3de63cc939ccd0e85 + languageName: node + linkType: hard + "react-native-is-edge-to-edge@npm:^1.3.1": version: 1.3.1 resolution: "react-native-is-edge-to-edge@npm:1.3.1" From 8212bcd580a808077bda8f8d40d5bb20c8785515 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 11 Oct 2026 15:05:34 +0300 Subject: [PATCH 2/2] test(native): put the Fabric UI manager back after each gesture-handler suite Jest's teardown reads every global, and expo's lazy fetch then loads the renderer through the suite's react-native mock. With nativeFabricUIManager still cleared, that throws, and on Linux Jest reports the suite as failed to run. --- src/__tests__/_gesture-handler.ts | 17 ++++++++++++++--- .../react-native-gesture-handler.test.tsx | 4 ++-- ...eact-native-gesture-handler-rewrite.test.tsx | 4 ++-- ...ct-native-gesture-handler-root-view.test.tsx | 4 ++-- .../react-native-gesture-handler.test.tsx | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/__tests__/_gesture-handler.ts b/src/__tests__/_gesture-handler.ts index ceaeca47..1ada80a7 100644 --- a/src/__tests__/_gesture-handler.ts +++ b/src/__tests__/_gesture-handler.ts @@ -5,9 +5,20 @@ interface RenderedNode { children: RenderedNode[] | null; } -// GestureHandlerRootView installs a native module under Fabric, so the jest preset's nativeFabricUIManager is cleared -export function disableFabric(): void { - Reflect.set(globalThis, "nativeFabricUIManager", undefined); +// GestureHandlerRootView installs a native module under Fabric, so a suite runs without the jest preset's nativeFabricUIManager, and puts it back because jest's teardown reads every global, and expo's lazy fetch then loads the renderer +export function withoutFabric(): void { + const nativeFabricUIManager: unknown = Reflect.get( + globalThis, + "nativeFabricUIManager", + ); + + beforeAll(() => { + Reflect.set(globalThis, "nativeFabricUIManager", undefined); + }); + + afterAll(() => { + Reflect.set(globalThis, "nativeFabricUIManager", nativeFabricUIManager); + }); } /** Every rendered element's props, at any depth. */ diff --git a/src/__tests__/compiler/react-native-gesture-handler.test.tsx b/src/__tests__/compiler/react-native-gesture-handler.test.tsx index 2f7a18c8..beb40e32 100644 --- a/src/__tests__/compiler/react-native-gesture-handler.test.tsx +++ b/src/__tests__/compiler/react-native-gesture-handler.test.tsx @@ -9,12 +9,12 @@ import * as RNGH from "react-native-gesture-handler"; import { deriveReDeclared, - disableFabric, flattenStyles, requiredProps, + withoutFabric, } from "../_gesture-handler"; -beforeAll(disableFabric); +withoutFabric(); // The compiler emits the same declaration whichever component consumes it, so the interop is a runtime mapping 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 0ec9bbca..825b8364 100644 --- a/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx @@ -6,13 +6,13 @@ import { registerCSS, testID } from "react-native-css/jest"; import { collectProps, deprecatedByGestureHandler, - disableFabric, flattenStyles, reachedByTheRewrite, requiredProps, + withoutFabric, } from "../_gesture-handler"; -beforeAll(disableFabric); +withoutFabric(); // The rewrite substitutes react-native-css/components for react-native everywhere but this package's own files let mockRewriting = false; 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 index ff86db7b..232d5383 100644 --- a/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx @@ -5,9 +5,9 @@ import { registerCSS, testID } from "react-native-css/jest"; import { collectProps, - disableFabric, flattenStyles, serializeRender, + withoutFabric, } from "../_gesture-handler"; // Jest resolves the iOS GestureHandlerRootView, so the Android variant, a native view the rewrite cannot reach, is substituted @@ -19,7 +19,7 @@ jest.mock( ), ); -beforeAll(disableFabric); +withoutFabric(); function styledRootView(): ComponentType> { return jest.requireActual>( diff --git a/src/__tests__/native/react-native-gesture-handler.test.tsx b/src/__tests__/native/react-native-gesture-handler.test.tsx index 73615929..7b508254 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -12,7 +12,6 @@ import { deprecatedByGestureHandler, deriveExcludedComponents, deriveReDeclared, - disableFabric, flattenStyles, functionPropNames, gestureHandlers, @@ -23,9 +22,10 @@ import { requiredProps, serializeRender, unobservable, + withoutFabric, } from "../_gesture-handler"; -beforeAll(disableFabric); +withoutFabric(); const styledExports = StyledRNGH as unknown as Record; const gestureHandlerExports = RNGH as unknown as Record;