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