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 @@ -241,6 +253,7 @@
"react": "19.1.0",
"react-native": "0.81.4",
"react-native-builder-bob": "^0.43.0",
"react-native-gesture-handler": "2.28.0",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "5.6.1",
"react-native-worklets": "~0.5.0",
Expand Down
195 changes: 195 additions & 0 deletions src/__tests__/_gesture-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Shared by the three gesture-handler suites — the two native ones and the compiler
* one. Nothing here imports `react-native` or `react-native-gesture-handler` at module
* scope: the rewrite suite mocks `react-native`, so a module-scope import would resolve
* gesture-handler's own imports through the mock and quietly change what the census
* means. Each suite hands its module objects in instead.
*/

interface RenderedNode {
props: Record<string, unknown>;
children: RenderedNode[] | null;
}

/**
* `GestureHandlerRootView` calls `maybeInitializeFabric()` while rendering, which
* reaches `RNGestureHandlerModule.install()` — a method the JS module carries only
* inside a native binary. React Native's own jest setup defines `nativeFabricUIManager`
* as `{}`, and `isFabric()` reads that global and nothing else, so clearing it takes
* the branch that never touches the module. Every suite that renders the re-declared
* census needs this, which is why it is here rather than in one of them.
*/
export function disableFabric(): void {
Reflect.set(globalThis, "nativeFabricUIManager", undefined);
}

/** Every rendered element's props, at any depth. */
export function collectProps(node: unknown): Record<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)];
}

/**
* Every rendered element's function-valued prop names, at any depth. `JSON.stringify`
* drops exactly the props whose value is a function, so a byte comparison of two trees
* is blind to a wrapper that swallows a handler — and these nodes carry up to four.
* This is the complement of what the byte comparison sees, which is what makes the two
* together a whole guard.
*
* The value TYPE is the discriminator, not the name. Gesture Handler's Pressable renders
* `testOnly_onPress={props.onPress}` unconditionally, so the KEY is present either way
* and `Object.keys()` reports no difference at all; only the value goes `undefined`.
*/
export function functionPropNames(node: unknown): string[][] {
return collectProps(node).map((props) =>
Object.entries(props)
.filter(([, value]) => typeof value === "function")
.map(([name]) => name)
.sort(),
);
}

/**
* Every style object in the tree, at any array depth. The merge nests — a Pressable
* carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a
* single level would report an absence that is really a depth.
*/
export function flattenStyles(node: unknown): Record<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));
}

/**
* Derived from the module, not restated: a member the wrapper re-declares is one whose
* export is no longer the one `export *` provided. Every generated case reads this, so
* a further re-declaration is covered the moment it lands.
*/
export function deriveReDeclared(
styledExports: Record<string, unknown>,
gestureHandlerExports: Record<string, unknown>,
): string[] {
return Object.keys(styledExports)
.filter((name) => styledExports[name] !== gestureHandlerExports[name])
.sort();
}

/**
* The exclusion register, executable. Every gesture-handler export sits in exactly one
* of these groups or in the derived re-declared set, so a member can be neither
* re-declared nor excluded only by failing the accounting test — which is how
* `PureNativeButton`, a sixth member of the button family, went unnoticed.
*/
export const notAComponent = [
"Directions",
"Gesture",
"GestureDetector",
"HoverEffect",
"MouseButton",
"PointerType",
"State",
"createNativeWrapper",
"enableExperimentalWebImplementation",
"enableLegacyWebImplementation",
"gestureHandlerRootHOC",
];

/** Handlers wrap a child; they render no view of their own for a style to land on. */
export const gestureHandlers = [
"FlingGestureHandler",
"ForceTouchGestureHandler",
"LongPressGestureHandler",
"NativeViewGestureHandler",
"PanGestureHandler",
"PinchGestureHandler",
"RotationGestureHandler",
"TapGestureHandler",
];

/** Reached by the `react-native` rewrite already — see the rewrite suite. */
export const reachedByTheRewrite = [
"FlatList",
"ScrollView",
"Switch",
"Text",
"TextInput",
];

/** className is dropped, and gesture-handler marks every one `@deprecated`. */
export const deprecatedByGestureHandler = [
"DrawerLayout",
"Swipeable",
"TouchableHighlight",
"TouchableNativeFeedback",
"TouchableOpacity",
"TouchableWithoutFeedback",
];

/** className is dropped, and no test at this tier can observe a fix. */
export const unobservable = ["RefreshControl"];

/** Every name the register gives a reason to, across the four component buckets. */
export const reasonedExclusions = [
...gestureHandlers,
...reachedByTheRewrite,
...deprecatedByGestureHandler,
...unobservable,
];

/**
* Derived, not the union of the buckets above: every export that renders and is not
* re-declared, whether or not anybody wrote a reason for it. The two differ exactly
* when a member has been missed, and the drop invariant is generated from THIS — so
* an unhandled component is rendered and held to the invariant rather than waiting
* for the accounting test to notice a name is absent from a list.
*
* The domain is `Object.keys` over the index module, and that is the limit of what
* deriving buys: ReanimatedDrawerLayout and ReanimatedSwipeable ship from their own
* entry points, so they are outside it permanently and no upstream change can enrol
* them here. Covering those two is an edit to this file, not a thing it notices.
*/
export function deriveExcludedComponents(
gestureHandlerExports: Record<string, unknown>,
reDeclared: string[],
): string[] {
return Object.keys(gestureHandlerExports)
.filter(
(name) => !reDeclared.includes(name) && !notAComponent.includes(name),
)
.sort();
}

/**
* Re-declared members that render no function-valued prop for the guard to compare, so
* its verdict on them is an equality between two empty sets. React Native's jest mock for
* the Android-only DrawerLayoutAndroid renders a debug placeholder `View` and forwards
* none of its props — not `testID`, not a callback — which is the same tier limit the
* RefreshControl exclusion stands on. Naming it is what stops the generated case reading
* as a measurement it is not, and the pinned test beside the block is what keeps the
* name honest.
*/
export const handlerUnobservable = ["DrawerLayoutAndroid"];

/** Props a component will not render at all without. */
export const requiredProps: Record<string, Record<string, unknown>> = {
DrawerLayout: { renderNavigationView: () => null },
DrawerLayoutAndroid: { renderNavigationView: () => null },
FlatList: { data: [], renderItem: () => null },
};
96 changes: 96 additions & 0 deletions src/__tests__/compiler/important.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { render } from "@testing-library/react-native";
import { compile } from "react-native-css/compiler";
import { Pressable } from "react-native-css/components/Pressable";
import { registerCSS, testID } from "react-native-css/jest";
import { Specificity } from "react-native-css/utilities";

/**
* `!important` is decided in the compiler and carried in the rule's specificity array,
* and the runtime reads that slot to choose which of its two merge passes a class takes.
* That makes the marker the compiler-plane half of a runtime defect: a `style` callback
* merged as data stops being a callback, and the two passes are separate code paths, so
* a guard on one says nothing about the other. The tests below pin the marker, then pin
* that a callback survives whichever pass the marker selects.
*/

const PLAIN = `.bg-red { background-color: red; }`;
const IMPORTANT = `.bg-red\\! { background-color: red !important; }`;

function ruleFor(css: string) {
const rule = compile(css).stylesheet().s?.[0]?.[1]?.[0];

if (rule === undefined) {
throw new Error(`compiled no rule for ${css}`);
}

return rule;
}

test("!important sets the Important slot, and nothing else moves", () => {
const plain = ruleFor(PLAIN);
const important = ruleFor(IMPORTANT);

// Read through the exported census rather than the literal index — the slot is
// named `Specificity.Important`, and a reshuffle of that enum has to move this
// assertion with it rather than leave it pointing at a neighbour.
expect(plain.s[Specificity.Important]).toBeUndefined();
expect(important.s[Specificity.Important]).toBe(1);

// The declaration itself is untouched: the marker is the only difference, which
// is what makes it the thing that selects the route.
expect(plain.d).toStrictEqual(important.d);
expect(plain.s[Specificity.ClassName]).toBe(
important.s[Specificity.ClassName],
);
});

test("the marker selects the merge pass, and the operand order is how you see it", () => {
registerCSS(`${PLAIN}\n${IMPORTANT}`);

// Without the marker the class merges as the inline pass: the class value goes
// first and the callback's result second, so the callback wins on a conflict.
expect(
render(
<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}`);

// `Pressable` picks its branch with `typeof style === "function"`. Merged into an
// array the answer is "object", the callback never runs, and the raw function
// reaches the native view — the entry below would be `[Function style]` instead of
// the object it returned, and every pressed-state style would be silently gone.
for (const className of ["bg-red", "bg-red!"]) {
const style = render(
<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");
}
}
});
Loading