From 13327e61967dbbdde6578312fed15d4658d5fb79 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 01:54:53 +0300 Subject: [PATCH 1/7] 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/7] 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; From 11273058e8bb707014852ffbe5552bc39b6fce44 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 21:48:42 +0300 Subject: [PATCH 3/7] fix(native): let a class that sizes GestureHandlerRootView replace its flex:1 default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gesture Handler's root falls back to `flex: 1` while no style is given, and the re-declaration kept that default under every class. Yoga reads a positive flex over an explicit or absent basis, so a root a class sizes to its content — `grow-0`, `shrink-0`, `basis-*`, `flex-none` — collapsed or stretched instead. The default now yields to a class that states its own flex sizing, and an inline style still displaces it, as Gesture Handler's own does. --- src/__tests__/_gesture-handler.ts | 32 +++++++--- ...-native-gesture-handler-root-view.test.tsx | 34 +++++++++++ .../react-native-gesture-handler.test.tsx | 22 ++++++- .../react-native-gesture-handler.native.tsx | 61 +++++++++++++++---- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/__tests__/_gesture-handler.ts b/src/__tests__/_gesture-handler.ts index 1ada80a7..2222fc3e 100644 --- a/src/__tests__/_gesture-handler.ts +++ b/src/__tests__/_gesture-handler.ts @@ -54,18 +54,32 @@ export function functionPropNames(node: unknown): string[][] { } // The merge nests style arrays, so styles are collected at every depth +function collectStyleEntries(style: unknown): Record[] { + if (Array.isArray(style)) { + return style.flatMap((entry) => collectStyleEntries(entry)); + } + + return style !== null && typeof style === "object" + ? [style as Record] + : []; +} + export function flattenStyles(node: unknown): Record[] { - const collect = (style: unknown): Record[] => { - if (Array.isArray(style)) { - return style.flatMap((entry) => collect(entry)); - } + return collectProps(node).flatMap((props) => + collectStyleEntries(props.style), + ); +} - return style !== null && typeof style === "object" - ? [style as Record] - : []; - }; +// What Yoga reads off the outermost element: its style entries merged in order, later ones winning +export function rootStyle(node: unknown): Record { + const [root] = collectProps(node); + const merged: Record = {}; + + for (const entry of collectStyleEntries(root?.style)) { + Object.assign(merged, entry); + } - return collectProps(node).flatMap((props) => collect(props.style)); + return merged; } export function deriveReDeclared( 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 232d5383..a6aec343 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 @@ -6,6 +6,7 @@ import { registerCSS, testID } from "react-native-css/jest"; import { collectProps, flattenStyles, + rootStyle, serializeRender, withoutFabric, } from "../_gesture-handler"; @@ -83,6 +84,39 @@ test("the flex:1 default survives the class on Android too", () => { ).toContainEqual(expect.objectContaining({ flex: 1 })); }); +// Yoga reads a positive flex over an explicit or absent basis, so a default left beside these would collapse a root sized to its content +test.each([ + ["grow-0", "flex-grow: 0", { flexGrow: 0 }], + ["shrink-0", "flex-shrink: 0", { flexShrink: 0 }], + ["basis-90", "flex-basis: 90px", { flexBasis: 90 }], + ["flex-none", "flex: none", { flexGrow: 0, flexShrink: 0 }], +])( + "a class stating its own flex sizing (%s) replaces the flex:1 default", + (className, declaration, sizing) => { + registerCSS(`.${className} { ${declaration}; }`); + + const style = rootStyle(renderWith(styledRootView(), { className })); + + expect(style).toMatchObject(sizing); + expect(style).not.toHaveProperty("flex"); + }, +); + +test("an inline style displaces the default, as Gesture Handler's own does", () => { + registerCSS(`.w-96 { width: 96px; }`); + + for (const props of [ + { style: { width: 95 } }, + { className: "w-96", style: { height: 94 } }, + ]) { + const style = rootStyle(renderWith(styledRootView(), props)); + + expect(style).not.toHaveProperty("flex"); + expect(style).not.toHaveProperty("flexGrow"); + expect(style).not.toHaveProperty("flexBasis"); + } +}); + 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 index 7b508254..c0254217 100644 --- a/src/__tests__/native/react-native-gesture-handler.test.tsx +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -20,6 +20,7 @@ import { reachedByTheRewrite, reasonedExclusions, requiredProps, + rootStyle, serializeRender, unobservable, withoutFabric, @@ -223,7 +224,7 @@ test("resolves a function style beside className on Pressable", () => { } }); -// GestureHandlerRootView falls back to flex: 1 only while style is absent, and a class must not count as a style +// GestureHandlerRootView falls back to flex: 1 only while style is absent, and a class counts as a style only when it sizes the root itself describe("GestureHandlerRootView's flex:1 default", () => { test("survives a className, which supplies a style where none was given", () => { registerCSS(`.w-95 { width: 95px; }`); @@ -262,6 +263,25 @@ describe("GestureHandlerRootView's flex:1 default", () => { expect(styles).toContainEqual(expect.objectContaining({ margin: 4 })); expect(styles).not.toContainEqual(expect.objectContaining({ flex: 1 })); }); + + test.each([ + ["grow-0", "flex-grow: 0", { flexGrow: 0 }], + ["shrink-0", "flex-shrink: 0", { flexShrink: 0 }], + ["basis-90", "flex-basis: 90px", { flexBasis: 90 }], + ["flex-none", "flex: none", { flexGrow: 0, flexShrink: 0 }], + ])( + "yields to a className that states its own flex sizing (%s)", + (className, declaration, sizing) => { + registerCSS(`.${className} { ${declaration}; }`); + + const style = rootStyle( + renderWith(StyledRNGH.GestureHandlerRootView, { className }), + ); + + expect(style).toMatchObject(sizing); + expect(style).not.toHaveProperty("flex"); + }, + ); }); test("re-exports the members it does not re-declare", () => { diff --git a/src/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx index 2c9e496a..ff3669ae 100644 --- a/src/components/react-native-gesture-handler.native.tsx +++ b/src/components/react-native-gesture-handler.native.tsx @@ -99,27 +99,62 @@ export const PureNativeButton = copyComponentProperties( }, ); +type RootViewProps = ComponentProps; + +// Gesture Handler applies its own flex: 1 through `style ?? styles.container`, which a resolved class would displace +const rootViewDefault = StyleSheet.create({ container: { flex: 1 } }); + +const flexSizingProperties = [ + "flex", + "flexGrow", + "flexShrink", + "flexBasis", +] as const; + +// Yoga reads a positive flex over an explicit or absent basis, so a class that sizes the root itself replaces the default rather than sitting beside it +function resolveRootViewStyle( + style: RootViewProps["style"], + hasInlineStyle: boolean, +): RootViewProps["style"] { + if (hasInlineStyle) { + return style; + } + if (style === undefined) { + return rootViewDefault.container; + } + const resolved = StyleSheet.flatten(style); + return flexSizingProperties.some( + (property) => resolved[property] !== undefined, + ) + ? style + : [style, rootViewDefault.container]; +} + +function RootViewWithDefault({ + hasInlineStyle, + style, + ...props +}: RootViewProps & { hasInlineStyle: boolean }) { + return ( + + ); +} + const gestureHandlerRootViewMapping: StyledConfiguration< - typeof RNGHGestureHandlerRootView + typeof RootViewWithDefault > = { 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 - >) => { + (props: StyledProps) => { return useCssElement( - RNGHGestureHandlerRootView, - { ...props, style: style ?? rootViewDefault.container }, + RootViewWithDefault, + { ...props, hasInlineStyle: props.style !== undefined }, gestureHandlerRootViewMapping, ); }, From e9eeb19446d7dc00837c1e79c041b2f895180dd3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 01:59:30 +0300 Subject: [PATCH 4/7] refactor(metro): share the babel plugin's test for this package's own modules The resolver computed the package root and the own-module test the babel import plugin already computes, and its copy dropped the trailing path separator, so a directory beside the package whose name merely begins with dist or src counted as the package's own and its imports were left unrewritten. Both now import one isFromThisModule. --- src/__tests__/metro/resolver.test.ts | 22 ++++++++++++++++++++++ src/babel/import-plugin.ts | 16 +--------------- src/babel/this-module.ts | 16 ++++++++++++++++ src/metro/resolver.ts | 17 ++--------------- 4 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 src/babel/this-module.ts diff --git a/src/__tests__/metro/resolver.test.ts b/src/__tests__/metro/resolver.test.ts index a4fdab05..ce8cd7e4 100644 --- a/src/__tests__/metro/resolver.test.ts +++ b/src/__tests__/metro/resolver.test.ts @@ -144,6 +144,28 @@ describe("nativeResolver", () => { }); }); + test.each([ + ["dist", join(packageRoot, "distribution", "index.js")], + ["src", join(packageRoot, "src-generated", "index.js")], + ])( + "rewrites an import from a directory whose name only begins with %s", + (_prefix, originModulePath) => { + const { resolver, calls } = recordingResolver(); + + nativeResolver( + resolver, + contextFor(originModulePath), + "react-native-gesture-handler", + "ios", + ); + + expect(calls).toEqual([ + ["react-native-gesture-handler", "ios"], + ["react-native-css/components/react-native-gesture-handler", "ios"], + ]); + }, + ); + test("leaves a resolution that is not a source file alone", () => { const calls: [string, string | null][] = []; const resolver: CustomResolver = (_context, moduleName, platform) => { diff --git a/src/babel/import-plugin.ts b/src/babel/import-plugin.ts index b199d8ca..a28c4264 100644 --- a/src/babel/import-plugin.ts +++ b/src/babel/import-plugin.ts @@ -1,5 +1,3 @@ -import { basename, dirname, resolve, sep } from "path"; - import { type PluginObj } from "@babel/core"; import type { Statement } from "@babel/types"; @@ -19,6 +17,7 @@ import { handleReactNativeWebInteropRequireDefault, handleReactNativeWebObjectPatternRequire, } from "./react-native-web"; +import { isFromThisModule } from "./this-module"; export default function ({ types: t, @@ -27,19 +26,6 @@ export default function ({ }): PluginObj { const processed = new WeakSet(); - const packageRoot = resolve( - __dirname, - basename(dirname(__dirname)) === "src" ? "../.." : "../../..", - ); - const thisModuleDist = resolve(packageRoot, "dist") + sep; - const thisModuleSrc = resolve(packageRoot, "src") + sep; - - function isFromThisModule(filename: string): boolean { - return ( - filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) - ); - } - return { name: "Rewrite react-native to react-native-css", visitor: { diff --git a/src/babel/this-module.ts b/src/babel/this-module.ts new file mode 100644 index 00000000..447d63a3 --- /dev/null +++ b/src/babel/this-module.ts @@ -0,0 +1,16 @@ +import { basename, dirname, resolve, sep } from "node:path"; + +// Anchored on the layout segment, since this runs from src/babel under the source condition and from dist//babel once built +const packageRoot = resolve( + __dirname, + basename(dirname(__dirname)) === "src" ? "../.." : "../../..", +); +const thisModuleDist = resolve(packageRoot, "dist") + sep; +const thisModuleSrc = resolve(packageRoot, "src") + sep; + +/** Whether `filename` is one of this package's own modules, which import the libraries it rewrites */ +export function isFromThisModule(filename: string): boolean { + return ( + filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) + ); +} diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts index e1783b6f..4261bde9 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -1,4 +1,4 @@ -import { basename, dirname, join, resolve, sep } from "node:path"; +import { basename, sep } from "node:path"; import type { CustomResolutionContext, @@ -7,20 +7,7 @@ import type { } from "metro-resolver"; import { allowedModules } from "../babel/allowedModules"; - -// 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 ( - filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) - ); -} +import { isFromThisModule } from "../babel/this-module"; export function nativeResolver( resolver: CustomResolver, From 99709ce3e58406703f22cc434da9d2a8af6d27b5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 11:31:35 +0300 Subject: [PATCH 5/7] refactor(native): combine two values of a style prop in one place deepMergeConfig combined a computed style value with another one three ways: flattened for important styles, an array collapsed to the inline value when it overrides every computed key for a one-segment array target, and a plain array for a one-segment string target. A new kind of style value had to be taught to all three, as the callback styles were. combineStyleValues is the one combination: a callback composes, a value that overrides every key of the other replaces it, and otherwise the two are flattened when this library computed both or layered when one came from the element. A one-segment string target now collapses as its array spelling does, so `{ target: "style" }` and `"style"` combine an inline style the same way. --- .../native/className-with-style.test.tsx | 65 +++++++++++ src/native/styles/index.ts | 101 +++++++++--------- 2 files changed, 113 insertions(+), 53 deletions(-) diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 500ed3ed..5d569bdd 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -611,3 +611,68 @@ describe("multi-config: inline style should not destroy className styles on othe ]); }); }); + +describe("both spellings of a style target combine an inline style the same way", () => { + const MAPPINGS = [ + ["the shorthand", { className: "style" }], + ["the object form", { className: { target: "style" } }], + ] as const satisfies readonly (readonly [ + string, + StyledConfiguration, + ])[]; + + test.each(MAPPINGS)( + "%s: an inline style that sets every property the class sets replaces it", + (_spelling, mapping) => { + registerCSS(`.bg-red { background-color: red; }`); + const StyledView = copyComponentProperties( + RNView, + ( + props: StyledProps< + React.ComponentProps, + typeof mapping + >, + ) => useCssElement(RNView, props, mapping), + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ backgroundColor: "blue" }); + }, + ); + + test.each(MAPPINGS)( + "%s: an inline style that sets other properties is layered over the class", + (_spelling, mapping) => { + registerCSS(`.bg-red { background-color: red; }`); + const StyledView = copyComponentProperties( + RNView, + ( + props: StyledProps< + React.ComponentProps, + typeof mapping + >, + ) => useCssElement(RNView, props, mapping), + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual([ + { backgroundColor: "#f00" }, + { height: 16 }, + ]); + }, + ); +}); diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 0f6a1161..e125a606 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -370,6 +370,43 @@ function composeStyleCallback(left: unknown, right: unknown): StyleCallback { ]; } +function isStyleObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// An inline style object can carry an identity a copy would lose (an animated style), so only computed values are flattened +type StyleLayering = "flattened" | "layered"; + +/** Combines two values of one style prop, `right` taking precedence */ +function combineStyleValues( + left: unknown, + right: unknown, + layering: StyleLayering, +): unknown { + if (isStyleCallback(left) || isStyleCallback(right)) { + return composeStyleCallback(left, right); + } + + if ( + isStyleObject(left) && + isStyleObject(right) && + !hasNonOverlappingProperties(left, right) + ) { + return right; + } + + switch (layering) { + case "flattened": + return flattenStyleArray( + Array.isArray(left) ? [...left, right] : [left, right], + ); + case "layered": + return [left, right]; + default: + return layering satisfies never; + } +} + function deepMergeConfig( config: Config, left: Record | undefined, @@ -396,31 +433,9 @@ function deepMergeConfig( if (left?.style) { result.style = left.style; } - } else if (!rightIsInline && right?.style) { + } else if (!rightIsInline && right?.style && left?.style) { // Merging non-inline styles (e.g., important styles) - if (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 ( - typeof left.style === "object" && - typeof right.style === "object" - ) { - // Both are objects, check for overlaps - if (hasNonOverlappingProperties(left.style, right.style)) { - result.style = flattenStyleArray([left.style, right.style]); - } else { - // All left properties are overridden by right - result.style = right.style; - } - } else { - // One or both are arrays/mixed types - const combined = [left.style, right.style]; - result.style = flattenStyleArray(combined); - } - } + result.style = combineStyleValues(left.style, right.style, "flattened"); } } else { result = mergeDefinedProps(left, right); @@ -470,27 +485,11 @@ function deepMergeConfig( delete result[finalKey]; } } else if (left && finalKey in left) { - const leftValue = left[finalKey]; - const leftIsObj = - typeof leftValue === "object" && - leftValue !== null && - !Array.isArray(leftValue); - const rightIsObj = - typeof rightValue === "object" && - rightValue !== null && - !Array.isArray(rightValue); - if (isStyleCallback(leftValue) || isStyleCallback(rightValue)) { - result[finalKey] = composeStyleCallback(leftValue, rightValue); - } else if (leftIsObj && rightIsObj) { - if (hasNonOverlappingProperties(leftValue, rightValue)) { - result[finalKey] = [leftValue, rightValue]; - } else { - // All left keys are in right — use filtered right value - result[finalKey] = rightValue; - } - } else { - result[finalKey] = [leftValue, rightValue]; - } + result[finalKey] = combineStyleValues( + left[finalKey], + rightValue, + "layered", + ); } else { // No left value — use filtered right value (not the unfiltered one from mergeDefinedProps) result[finalKey] = rightValue; @@ -514,14 +513,10 @@ function deepMergeConfig( } if (rightValue !== undefined) { - if (left && target in left) { - result[target] = - isStyleCallback(left[target]) || isStyleCallback(rightValue) - ? composeStyleCallback(left[target], rightValue) - : [left[target], rightValue]; - } else { - result[target] = rightValue; - } + result[target] = + left && target in left + ? combineStyleValues(left[target], rightValue, "layered") + : rightValue; } return result; From 34b5eefdcbe809c1d912b9403cd5a76ef55488a8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:41:24 +0300 Subject: [PATCH 6/7] fix(native): pass an inline style that holds no CSS variable through as it came filterCssVariables rebuilt every inline object, so an inline style lost the identity the layering keeps, and an Animated value inside one reached the component as a plain copy no animation drives. It now copies only what holds a variable, an undefined entry or a Symbol key, and leaves a class instance whole. --- .../native/className-with-style.test.tsx | 68 ++++++++++- src/native/styles/index.ts | 114 ++++++++++-------- 2 files changed, 128 insertions(+), 54 deletions(-) diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 5d569bdd..852f7803 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -1,4 +1,8 @@ -import { Pressable as RNPressable, View as RNView } from "react-native"; +import { + Animated, + Pressable as RNPressable, + View as RNView, +} from "react-native"; import { render } from "@testing-library/react-native"; import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; @@ -676,3 +680,65 @@ describe("both spellings of a style target combine an inline style the same way" }, ); }); + +describe("an inline value holding no CSS variable reaches the component as it came", () => { + const MAPPINGS = [ + ["style", { className: "style" }, "style"], + ["{ target: style }", { className: { target: "style" } }, "style"], + [ + "contentContainerStyle", + { className: "contentContainerStyle" }, + "contentContainerStyle", + ], + [ + "{ target: contentContainerStyle }", + { className: { target: "contentContainerStyle" } }, + "contentContainerStyle", + ], + ] as const; + + function renderReceived( + mapping: (typeof MAPPINGS)[number][1], + prop: (typeof MAPPINGS)[number][2], + value: unknown, + ): unknown { + registerCSS(`.padded { padding: 4px; }`); + const received: Readonly>[] = []; + const Probe = (props: Readonly>) => { + received.push(props); + return null; + }; + const Styled = (props: Readonly>) => + useCssElement(Probe, props, mapping); + + render(); + return received.at(-1)?.[prop]; + } + + test.each(MAPPINGS)( + "%s: the inline style is layered over the class as the same object", + (_spelling, mapping, prop) => { + const inlineStyle = { height: 16 }; + + const received = renderReceived(mapping, prop, inlineStyle); + + expect(received).toStrictEqual([{ padding: 4 }, { height: 16 }]); + expect((received as readonly unknown[])[1]).toBe(inlineStyle); + }, + ); + + test.each(MAPPINGS)( + "%s: an Animated value in the inline style is the same instance", + (_spelling, mapping, prop) => { + const opacity = new Animated.Value(0.5); + + const received = renderReceived(mapping, prop, { opacity }); + + expect(received).toStrictEqual([{ padding: 4 }, { opacity }]); + expect( + ((received as readonly unknown[])[1] as { readonly opacity: unknown }) + .opacity, + ).toBe(opacity); + }, + ); +}); diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index e125a606..54173940 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -83,81 +83,89 @@ function flattenStyleArray(styleArray: any[]): any { } /** - * Recursively filters out CSS variable objects (with VAR_SYMBOL) from style values. - * This prevents CSS variable runtime objects from leaking into React Native component props. - * - * @param value - The value to filter (can be any type: object, array, primitive, etc.) - * @param depth - Internal recursion depth counter to prevent stack overflow (max 100) - * @returns The filtered value with CSS variables removed, or `undefined` if the entire value - * should be filtered out (e.g., empty arrays, objects with only VAR_SYMBOL properties) - * - * Filtering behavior: - * - Objects with VAR_SYMBOL property: returns `undefined` (completely filtered) - * - Arrays: filters out VAR_SYMBOL objects, returns `undefined` if empty after filtering - * - Objects: recursively filters properties, returns `undefined` if no properties remain - * - Primitives (null, undefined, numbers, strings, booleans): returned as-is - * - Symbol properties: intentionally filtered out for React Native compatibility + * Removes the CSS variable objects (with VAR_SYMBOL) an inline value holds, so none reaches a React Native prop. + * A value that holds none comes back as it came, so an inline style keeps its identity and a class instance in it, + * such as an Animated value, is never rebuilt; an array or plain object that held one, an `undefined` entry or a + * Symbol key comes back rebuilt without it, and one left empty comes back `undefined`. * * @example * filterCssVariables({fontSize: 16, color: {[VAR_SYMBOL]: true}}) // {fontSize: 16} * filterCssVariables([{margin: 10}, {[VAR_SYMBOL]: true}]) // [{margin: 10}] * filterCssVariables({color: {[VAR_SYMBOL]: true}}) // undefined (all props filtered) */ -function filterCssVariables(value: any, depth = 0): any | undefined { +function filterCssVariables(value: unknown, depth = 0): unknown { // Prevent stack overflow on deeply nested structures - if (depth > 100) { + if (depth > 100 || typeof value !== "object" || value === null) { return value; } - if (value === null || value === undefined) { - return value; + if (Object.prototype.hasOwnProperty.call(value, VAR_SYMBOL)) { + return undefined; } if (Array.isArray(value)) { - // Single-pass filter with map operation - const filtered: any[] = []; - - for (const item of value) { - const filteredItem = filterCssVariables(item, depth + 1); - if ( - filteredItem !== undefined && - !( - typeof filteredItem === "object" && - filteredItem !== null && - Object.prototype.hasOwnProperty.call(filteredItem, VAR_SYMBOL) - ) - ) { - filtered.push(filteredItem); + return filterCssVariableArray(value as readonly unknown[], depth); + } + + return isPlainObject(value) + ? filterCssVariableObject(value as Readonly>, depth) + : value; +} + +function filterCssVariableArray( + array: readonly unknown[], + depth: number, +): unknown { + let filtered: unknown[] | undefined; + + for (const [index, item] of array.entries()) { + const kept = filterCssVariables(item, depth + 1); + if (filtered === undefined) { + if (kept === item && kept !== undefined) { + continue; } + filtered = array.slice(0, index); + } + if (kept !== undefined) { + filtered.push(kept); } - - return filtered.length > 0 ? filtered : undefined; } - if (typeof value === "object") { - // If the object itself has VAR_SYMBOL, filter it out (check own property only) - if (Object.prototype.hasOwnProperty.call(value, VAR_SYMBOL)) { - return undefined; - } + const result = filtered ?? array; + return result.length > 0 ? result : undefined; +} - // Otherwise, filter VAR_SYMBOL properties from nested objects - const filtered: Record = {}; - let hasProperties = false; - - // Use Object.keys to only iterate own string properties (not inherited, not Symbols) - // This intentionally filters out Symbol properties for React Native compatibility - for (const key of Object.keys(value)) { - const filteredValue = filterCssVariables(value[key], depth + 1); - if (filteredValue !== undefined) { - filtered[key] = filteredValue; - hasProperties = true; +function filterCssVariableObject( + object: Readonly>, + depth: number, +): unknown { + const keys = Object.keys(object); + let filtered: Record | undefined = + Object.getOwnPropertySymbols(object).length > 0 ? {} : undefined; + + for (const [index, key] of keys.entries()) { + const value = object[key]; + const kept = filterCssVariables(value, depth + 1); + if (filtered === undefined) { + if (kept === value && kept !== undefined) { + continue; } + filtered = Object.fromEntries( + keys.slice(0, index).map((earlier) => [earlier, object[earlier]]), + ); + } + if (kept !== undefined) { + filtered[key] = kept; } - - return hasProperties ? filtered : undefined; } - return value; + const result = filtered ?? object; + return Object.keys(result).length > 0 ? result : undefined; +} + +function isPlainObject(value: object): boolean { + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } export const stylesFamily = family( From 59f5c0b1f120b656f2ebdacee2bcb3c0b115cb2a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:44:53 +0300 Subject: [PATCH 7/7] test(native): hold an inline style's identity where no class style meets it --- src/__tests__/native/className-with-style.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 852f7803..82c625d7 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -701,6 +701,7 @@ describe("an inline value holding no CSS variable reaches the component as it ca mapping: (typeof MAPPINGS)[number][1], prop: (typeof MAPPINGS)[number][2], value: unknown, + className = "padded", ): unknown { registerCSS(`.padded { padding: 4px; }`); const received: Readonly>[] = []; @@ -711,7 +712,7 @@ describe("an inline value holding no CSS variable reaches the component as it ca const Styled = (props: Readonly>) => useCssElement(Probe, props, mapping); - render(); + render(); return received.at(-1)?.[prop]; } @@ -741,4 +742,15 @@ describe("an inline value holding no CSS variable reaches the component as it ca ).toBe(opacity); }, ); + + test.each(MAPPINGS)( + "%s: with no class style beside it, the inline style is the same object", + (_spelling, mapping, prop) => { + const inlineStyle = { height: 16 }; + + expect(renderReceived(mapping, prop, inlineStyle, "unstyled")).toBe( + inlineStyle, + ); + }, + ); });