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..2222fc3e --- /dev/null +++ b/src/__tests__/_gesture-handler.ts @@ -0,0 +1,171 @@ +// 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 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. */ +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 +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[] { + return collectProps(node).flatMap((props) => + collectStyleEntries(props.style), + ); +} + +// 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 merged; +} + +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..beb40e32 --- /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, + flattenStyles, + requiredProps, + withoutFabric, +} from "../_gesture-handler"; + +withoutFabric(); + +// 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..ce8cd7e4 --- /dev/null +++ b/src/__tests__/metro/resolver.test.ts @@ -0,0 +1,249 @@ +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.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) => { + 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..ea6415c3 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -1,8 +1,13 @@ -import { 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"; 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"; @@ -12,6 +17,10 @@ import { type StyledConfiguration, type StyledProps, } from "react-native-css/native"; +import { VAR_SYMBOL } from "react-native-css/native/reactivity"; + +// What vars({ "--brand": "red" }) returns +const brandVariable = { [VAR_SYMBOL]: "inline", brand: "red" }; test("className with inline style props should coexist when different properties", () => { registerCSS(`.text-red { color: red; }`); @@ -68,6 +77,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; } @@ -530,3 +619,325 @@ 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 }, + ]); + }, + ); +}); + +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, + className = "padded", + ): 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); + }, + ); + + 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, + ); + }, + ); +}); + +describe("every spelling of a style target combines its class, inline and !important values the same way", () => { + const SPELLINGS = [ + ["the shorthand", { className: "style" }, "style"], + ["the object form", { className: { target: "style" } }, "style"], + [ + "a shorthand into contentContainerStyle", + { className: "contentContainerStyle" }, + "contentContainerStyle", + ], + [ + "an object form into contentContainerStyle", + { className: { target: "contentContainerStyle" } }, + "contentContainerStyle", + ], + ] as const; + + function received( + mapping: (typeof SPELLINGS)[number][1], + prop: (typeof SPELLINGS)[number][2], + className: string, + value?: unknown, + ): unknown { + registerCSS(` + .padded { padding: 4px; } + .bg-imp { background-color: red !important; } + .themed { color: var(--brand, blue); } + `); + const props: Readonly>[] = []; + const Probe = (probeProps: Readonly>) => { + props.push(probeProps); + return null; + }; + const Styled = (styledProps: Readonly>) => + useCssElement(Probe, styledProps, mapping); + + render( + , + ); + return props.at(-1)?.[prop]; + } + + test.each(SPELLINGS)( + "%s: an inline value that adds no style leaves the class's", + (_spelling, mapping, prop) => { + for (const value of [null, false, [], {}, { height: undefined }]) { + expect(received(mapping, prop, "padded", value)).toStrictEqual({ + padding: 4, + }); + } + }, + ); + + test.each(SPELLINGS)( + "%s: an inline vars() sets the variable and reaches the component as no style of its own", + (_spelling, mapping, prop) => { + expect( + received(mapping, prop, "padded themed", brandVariable), + ).toStrictEqual({ padding: 4, color: "red" }); + }, + ); + + test.each(SPELLINGS)( + "%s: an !important class layers over the inline value, which is the same object", + (_spelling, mapping, prop) => { + const inline = { height: 16 }; + + const value = received(mapping, prop, "padded bg-imp", inline); + + expect(value).toStrictEqual([ + { padding: 4 }, + { height: 16 }, + { backgroundColor: "#f00" }, + ]); + expect((value as readonly unknown[])[1]).toBe(inline); + }, + ); + + test.each(SPELLINGS)( + "%s: an !important class with no inline value flattens into the class's", + (_spelling, mapping, prop) => { + expect(received(mapping, prop, "padded bg-imp")).toStrictEqual({ + padding: 4, + backgroundColor: "#f00", + }); + }, + ); +}); + +describe("an inline value the CSS variable filter finds nothing in reaches the component as it came", () => { + function receivedStyle(style: unknown): unknown { + registerCSS(`.padded { padding: 4px; }`); + const props: Readonly>[] = []; + const Probe = (probeProps: Readonly>) => { + props.push(probeProps); + return null; + }; + const Styled = (styledProps: Readonly>) => + useCssElement(Probe, styledProps, { className: "style" }); + + render(); + return (props.at(-1)?.style as readonly unknown[])[1]; + } + + test("an empty transform, which clears the class's, and NaN", () => { + const inline = { height: 16, transform: [], opacity: Number.NaN }; + + expect(receivedStyle(inline)).toBe(inline); + }); + + test("a style array's undefined layer, which React Native skips", () => { + const inline = [{ height: 16 }, undefined]; + + expect(receivedStyle(inline)).toBe(inline); + }); + + test("an accessor, which the filter never runs", () => { + const read = jest.fn(() => 0.5); + const inline = { height: 16 }; + Object.defineProperty(inline, "opacity", { enumerable: true, get: read }); + + expect(receivedStyle(inline)).toBe(inline); + expect(read).not.toHaveBeenCalled(); + }); + + test("an accessor beside a CSS variable is copied without running", () => { + const read = jest.fn(() => 0.5); + const inline: Record = { + height: 16, + color: brandVariable, + }; + Object.defineProperty(inline, "opacity", { enumerable: true, get: read }); + + const kept = receivedStyle(inline) as Readonly>; + + expect(Object.keys(kept)).toStrictEqual(["height", "opacity"]); + expect(read).not.toHaveBeenCalled(); + }); +}); + +test("Pressable: a callback style between a class and an !important class keeps all three, in order", () => { + registerCSS(` + .padded { padding: 4px; } + .bg-imp { background-color: red !important; } + `); + + const component = render( + ({ opacity: pressed ? 0.5 : 1 })} + />, + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual([ + { padding: 4 }, + { opacity: 1 }, + { backgroundColor: "#f00" }, + ]); +}); + +test("a target:false mapping's !important value beats the element's value of that prop", () => { + registerCSS(`.label { color: red !important; }`); + const props: Readonly>[] = []; + const Probe = (probeProps: Readonly>) => { + props.push(probeProps); + return null; + }; + const Labelled = (styledProps: Readonly>) => + useCssElement(Probe, styledProps, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "accessibilityLabel" }, + }, + }); + + render(); + + expect(props.at(-1)?.accessibilityLabel).toBe("#f00"); +}); 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..825b8364 --- /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, + flattenStyles, + reachedByTheRewrite, + requiredProps, + withoutFabric, +} from "../_gesture-handler"; + +withoutFabric(); + +// 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..a6aec343 --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler-root-view.test.tsx @@ -0,0 +1,124 @@ +import type { ComponentType } from "react"; + +import { render } from "@testing-library/react-native"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { + collectProps, + flattenStyles, + rootStyle, + serializeRender, + withoutFabric, +} 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", + ), +); + +withoutFabric(); + +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 })); +}); + +// 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 new file mode 100644 index 00000000..c0254217 --- /dev/null +++ b/src/__tests__/native/react-native-gesture-handler.test.tsx @@ -0,0 +1,388 @@ +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, + flattenStyles, + functionPropNames, + gestureHandlers, + handlerUnobservable, + notAComponent, + reachedByTheRewrite, + reasonedExclusions, + requiredProps, + rootStyle, + serializeRender, + unobservable, + withoutFabric, +} from "../_gesture-handler"; + +withoutFabric(); + +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 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; }`); + + 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.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", () => { + 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/__tests__/native/rightIsInline.test.tsx b/src/__tests__/native/rightIsInline.test.tsx index dc8bc781..710b5f74 100644 --- a/src/__tests__/native/rightIsInline.test.tsx +++ b/src/__tests__/native/rightIsInline.test.tsx @@ -9,6 +9,8 @@ * runtime behavior with intentionally malformed data. */ +import { StyleSheet } from "react-native"; + import { render } from "@testing-library/react-native"; import { Text } from "react-native-css/components/Text"; import { View } from "react-native-css/components/View"; @@ -576,8 +578,8 @@ describe("rightIsInline - Important Style Interactions", () => { />, ).getByTestId(testID); - // Important overrides, VAR_SYMBOL filtered, literal kept - expect(component.props.style).toEqual({ + // Important overrides, VAR_SYMBOL filtered, literal kept; the inline style stays a layer of its own + expect(StyleSheet.flatten(component.props.style)).toEqual({ color: "#f00", fontSize: 12, lineHeight: 24, @@ -604,13 +606,14 @@ describe("rightIsInline - Important Style Interactions", () => { , ).getByTestId(testID); - // All important properties override inline - expect(component.props.style).toEqual({ + // All important properties override inline, which stays a layer of its own + expect(StyleSheet.flatten(component.props.style)).toEqual({ color: "#f00", fontSize: 14, fontWeight: "bold", lineHeight: 24, }); + expect(component.props.style).toContain(inlineStyle); }); }); 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/components/react-native-gesture-handler.native.tsx b/src/components/react-native-gesture-handler.native.tsx new file mode 100644 index 00000000..ff3669ae --- /dev/null +++ b/src/components/react-native-gesture-handler.native.tsx @@ -0,0 +1,183 @@ +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); + }, +); + +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 RootViewWithDefault +> = { + className: "style", +}; + +export const GestureHandlerRootView = copyComponentProperties( + RNGHGestureHandlerRootView, + (props: StyledProps) => { + return useCssElement( + RootViewWithDefault, + { ...props, hasInlineStyle: props.style !== undefined }, + 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..4261bde9 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -1,4 +1,4 @@ -import { basename, resolve, sep } from "node:path"; +import { basename, sep } from "node:path"; import type { CustomResolutionContext, @@ -7,15 +7,7 @@ import type { } from "metro-resolver"; import { allowedModules } from "../babel/allowedModules"; - -const thisModuleDist = resolve(__dirname, "../../../dist"); -const thisModuleSrc = resolve(__dirname, "../../../src"); - -function isFromThisModule(filename: string): boolean { - return ( - filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) - ); -} +import { isFromThisModule } from "../babel/this-module"; export function nativeResolver( resolver: CustomResolver, @@ -41,6 +33,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..84af808e 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -83,81 +83,114 @@ 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, and an object's `undefined` keys, so + * neither reaches a React Native prop; a style array keeps its `null` and `undefined` layers, which React Native + * skips. A value that holds neither comes back as it came, an empty one included, so an inline style keeps its + * identity and a class instance in it, such as an Animated value, is never rebuilt; one that held some comes back + * rebuilt without them, and `undefined` once nothing is left. Only data properties are read, so an accessor, such as + * a Reanimated shared value's `value`, never runs. * * @example * filterCssVariables({fontSize: 16, color: {[VAR_SYMBOL]: true}}) // {fontSize: 16} - * filterCssVariables([{margin: 10}, {[VAR_SYMBOL]: true}]) // [{margin: 10}] + * filterCssVariables([{margin: 10}, {[VAR_SYMBOL]: true}, null]) // [{margin: 10}, null] * 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 (let index = 0; index < array.length; index++) { + const item = array[index]; + const kept = filterCssVariables(item, depth + 1); + // A layer that filtered to nothing leaves, and an undefined layer as written stays + const leaves = kept === undefined && item !== undefined; + if (filtered === undefined) { + if (!leaves && Object.is(kept, item)) { + continue; } + filtered = array.slice(0, index); + } + if (!leaves) { + 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; - } + if (filtered === undefined) { + return array; + } + return filtered.length > 0 ? filtered : 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; + + let seen = 0; + for (const key of keys) { + const index = seen++; + const descriptor = Object.getOwnPropertyDescriptor(object, key); + const value: unknown = descriptor?.value; + const kept = + descriptor === undefined || !("value" in descriptor) + ? value + : filterCssVariables(value, depth + 1); + const isAccessor = descriptor !== undefined && !("value" in descriptor); + const leaves = !isAccessor && kept === undefined; + if (filtered === undefined) { + if (isAccessor || (!leaves && Object.is(kept, value))) { + continue; } + filtered = {}; + for (const earlier of keys.slice(0, index)) { + const earlierDescriptor = Object.getOwnPropertyDescriptor( + object, + earlier, + ); + if (earlierDescriptor !== undefined) { + Object.defineProperty(filtered, earlier, earlierDescriptor); + } + } + } + if (isAccessor) { + Object.defineProperty(filtered, key, descriptor); + } else if (!leaves) { + filtered[key] = kept; } + } - return hasProperties ? filtered : undefined; + if (filtered === undefined) { + return object; } + return Object.keys(filtered).length > 0 ? filtered : undefined; +} - return value; +function isPlainObject(value: object): boolean { + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } export const stylesFamily = family( @@ -205,16 +238,9 @@ export function getStyledProps( config, nativeStyleMapping(config, adaptProps(styledProps?.normal)), inline, - true, - ); - - if (styledProps?.important) { - result = deepMergeConfig( - config, - result, + styledProps?.important && nativeStyleMapping(config, adaptProps(styledProps.important)), - ); - } + ); if (result && config.target) { const path = Array.isArray(config.target) @@ -324,8 +350,8 @@ export function getStyledProps( * * Empty object detection uses inline `for...in` instead of a separate function or * `Object.keys().length` — avoids both function call overhead and intermediate array - * allocation on a hot path (called per-prop per-render). Consistent with how the - * `["style"]` target path handles this via `filterCssVariables({})` returning `undefined`. + * allocation on a hot path (called per-prop per-render). A config's target adds nothing + * for an empty object the same way, through `addsStyle`. * * @param left - The computed NativeWind props (className-derived styles) * @param right - The inline props from the component (guaranteed non-null by caller; may contain undefined values) @@ -356,191 +382,185 @@ function mergeDefinedProps( return result; } -function deepMergeConfig( - config: Config, - left: Record | undefined, - right: Record | undefined | null, - rightIsInline = false, -) { - if (!right) { - return { ...left }; - } +type StyleCallback = (state: unknown) => unknown; - // Handle style merging to support both className and inline style props - let result: Record; - if (config.target) { - if ( - Array.isArray(config.target) && - config.target.length === 1 && - config.target[0] === "style" - ) { - // Special handling for style target when we have inline styles - result = { ...left, ...right }; +function isStyleCallback(value: unknown): value is StyleCallback { + return typeof value === "function"; +} - // Handle null/undefined inline styles - if (right?.style === null || right?.style === undefined) { - 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)) { - 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); - } - } - } - } else { - result = mergeDefinedProps(left, right); +function isStyleObject( + value: unknown, +): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// A falsy style, an empty style array and an empty style object, such as contentContainerStyle={{}}, add nothing to a style prop +function addsStyle(value: unknown): boolean { + if (value === undefined || value === null || value === false) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (isStyleObject(value)) { + for (const _ in value) { + return true; } - } else { - result = mergeDefinedProps(left, right); + return false; + } + return true; +} + +// A later style object that sets every key of an earlier one hides it, as React Native's flattening would +function hides(later: unknown, earlier: unknown): boolean { + return ( + isStyleObject(later) && + isStyleObject(earlier) && + !hasNonOverlappingProperties(earlier, later) + ); +} + +// Pressable calls a function style with its state, so a style prop holding one stays a function +function composeStyleCallback( + computed: unknown, + inline: unknown, + important: unknown, +): StyleCallback { + return (state) => + [computed, inline, important] + .filter(addsStyle) + .map((layer) => (isStyleCallback(layer) ? layer(state) : layer)); +} + +// Both values come from this library, so they flatten into one object, which no caller holds +function combineComputedStyles(computed: unknown, important: unknown): unknown { + if (!addsStyle(important)) { + return computed; + } + if (!addsStyle(computed) || hides(important, computed)) { + return important; + } + return flattenStyleArray( + Array.isArray(computed) + ? [...(computed as readonly unknown[]), important] + : [computed, important], + ); +} + +/** + * Combines a style prop's three values in the cascade's order: the class's, the element's inline value, then the + * class's `!important` declarations ([CSS Cascade 5 §6.1](https://www.w3.org/TR/css-cascade-5/#cascade-sort)). + * The inline value is layered and never copied, so it keeps its identity, such as an animated style's; a value that + * the next one hides is left out. + */ +function combineStyleLayers( + computed: unknown, + inline: unknown, + important: unknown, +): unknown { + const addsComputed = addsStyle(computed); + const addsInline = addsStyle(inline); + const addsImportant = addsStyle(important); + + if (!addsInline || (addsImportant && hides(important, inline))) { + return combineComputedStyles(computed, important); } if ( - right && - rightIsInline && - config.source in right && - config.target !== config.source + (addsComputed && isStyleCallback(computed)) || + isStyleCallback(inline) || + (addsImportant && isStyleCallback(important)) ) { - delete result[config.source]; + return addsComputed || addsImportant + ? composeStyleCallback(computed, inline, important) + : inline; } - /** - * If target is a path, deep merge until we get to the last key - */ - if (Array.isArray(config.target)) { - if (config.target.length > 1) { - const key = config.target[0]!; - result[key] = deepMergeConfig( - { source: config.source, target: config.target.slice(1) }, - left?.[key], - right?.[key], - rightIsInline, - ); - } - - // For length-1 array targets (e.g. ["contentContainerStyle"]), the loop - // above runs 0 iterations. Merge the target prop so inline styles don't - // silently overwrite className-computed styles (same as string target path). - const finalKey = config.target[config.target.length - 1]; - if (config.target.length === 1 && finalKey && rightIsInline) { - let rightValue = right?.[finalKey]; - if (rightValue !== undefined) { - rightValue = filterCssVariables(rightValue); - } - if (rightValue === undefined || rightValue === null) { - // Inline is empty or fully filtered — preserve className-computed value - if (left && finalKey in left) { - result[finalKey] = left[finalKey]; - } else { - // No left value either — remove unfiltered inline value from result - 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 (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]; - } - } else { - // No left value — use filtered right value (not the unfiltered one from mergeDefinedProps) - result[finalKey] = rightValue; - } - } + const below = addsComputed && !hides(inline, computed) ? computed : undefined; + if (below === undefined) { + return addsImportant ? [inline, important] : inline; + } + return addsImportant ? [below, inline, important] : [below, inline]; +} - return result; +/** + * Merges a config's computed props with the element's inline props and the class's `!important` ones. Every prop + * takes the inline value over the computed one, and an `!important` one over both; the config's target, whichever + * way the mapping spells it, combines its three values as a style prop, through every object on its path. + */ +function deepMergeConfig( + config: Config, + normal: Readonly> | undefined, + inline: Readonly> | undefined | null, + important: Readonly> | undefined, +): Record { + const result: Record = mergeDefinedProps( + normal, + inline ?? {}, + ); + + if (inline && config.source in inline && config.target !== config.source) { + Reflect.deleteProperty(result, config.source); } const target = config.target; - if (target === undefined || target === false) { - return result; + return important ? mergeDefinedProps(result, important) : result; } - let rightValue = right?.[target]; + combineAtPath(result, target, 0, normal, inline ?? undefined, important); + return result; +} + +function propsAt( + props: Readonly> | undefined, + key: string, +): Readonly> | undefined { + const value = props?.[key]; + return isStyleObject(value) ? value : undefined; +} - // Strip any inline variables from the target - if (rightIsInline && rightValue !== undefined) { - rightValue = filterCssVariables(rightValue); +// A target is a key or a path of keys, read by index: Babel compiles a destructured path to a helper call and a copy on every render +function combineAtPath( + container: Record, + target: string | readonly string[], + depth: number, + normal: Readonly> | undefined, + inline: Readonly> | undefined, + important: Readonly> | undefined, +): void { + const key = typeof target === "string" ? target : target[depth]; + if (key === undefined) { + return; } - if (rightValue !== undefined) { - result[target] = - left && target in left ? [left[target], rightValue] : rightValue; + if (typeof target === "string" || depth === target.length - 1) { + const combined = combineStyleLayers( + normal?.[key], + filterCssVariables(inline?.[key]), + important?.[key], + ); + if (combined === undefined) { + Reflect.deleteProperty(container, key); + } else { + container[key] = combined; + } + return; } - return result; + const next = mergeDefinedProps( + propsAt(normal, key), + propsAt(inline, key) ?? {}, + ); + combineAtPath( + next, + target, + depth + 1, + propsAt(normal, key), + propsAt(inline, key), + propsAt(important, key), + ); + container[key] = next; } function nativeStyleMapping( 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"