Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions src/__tests__/_gesture-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Nothing here imports react-native at module scope, since the rewrite suite mocks it

interface RenderedNode {
props: Record<string, unknown>;
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<string, unknown>[] {
if (node === null || typeof node !== "object") {
return [];
}

if (Array.isArray(node)) {
return node.flatMap((child) => collectProps(child));
}

const { props, children } = node as RenderedNode;

return [props, ...collectProps(children)];
}

// handlerTag counts handlers as they mount, so two renders of one tree differ only there
export function serializeRender(node: unknown): string {
return JSON.stringify(node, (key, value: unknown) =>
key === "handlerTag" ? undefined : value,
);
}

// JSON.stringify drops function props, so handlers are compared by the value's type; Pressable renders testOnly_onPress whether or not one was passed
export function functionPropNames(node: unknown): string[][] {
return collectProps(node).map((props) =>
Object.entries(props)
.filter(([, value]) => typeof value === "function")
.map(([name]) => name)
.sort(),
);
}

// The merge nests style arrays, so styles are collected at every depth
export function flattenStyles(node: unknown): Record<string, unknown>[] {
const collect = (style: unknown): Record<string, unknown>[] => {
if (Array.isArray(style)) {
return style.flatMap((entry) => collect(entry));
}

return style !== null && typeof style === "object"
? [style as Record<string, unknown>]
: [];
};

return collectProps(node).flatMap((props) => collect(props.style));
}

export function deriveReDeclared(
styledExports: Record<string, unknown>,
gestureHandlerExports: Record<string, unknown>,
): 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<string, unknown>,
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<string, Record<string, unknown>> = {
DrawerLayout: { renderNavigationView: () => null },
DrawerLayoutAndroid: { renderNavigationView: () => null },
FlatList: { data: [], renderItem: () => null },
};
78 changes: 78 additions & 0 deletions src/__tests__/compiler/important.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Pressable
testID={testID}
className="bg-red"
style={() => ({ backgroundColor: "blue" })}
/>,
).getByTestId(testID).props.style,
).toStrictEqual([{ backgroundColor: "#f00" }, { backgroundColor: "blue" }]);

// Reversed with the marker: the important declaration is rightmost and wins.
expect(
render(
<Pressable
testID={testID}
className="bg-red!"
style={() => ({ 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(
<Pressable
testID={testID}
className={className}
style={({ pressed }) => ({ 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");
}
}
});
111 changes: 111 additions & 0 deletions src/__tests__/compiler/react-native-gesture-handler.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const gestureHandlerExports = RNGH as unknown as Record<string, unknown>;

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<string, unknown>,
): unknown {
const Component = component as ComponentType<Record<string, unknown>>;

return render(<Component testID={testID} {...props} />).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<string, unknown>,
];

/** One react-native primitive, then every gesture-handler member the wrapper re-declares. */
const consumers: Consumer[] = [
["react-native View", View, {}],
...reDeclared.map<Consumer>((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 }));
});
});
Loading