Skip to content

Commit 29bdd9b

Browse files
fix: re-declare GestureHandlerRootView, and guard the handlers a byte diff drops
`GestureHandlerRootView` sat in the exclusion register's `notAComponent` bucket, which the generated invariants subtract, so nothing rendered it. It renders two ways. The default is `<View style={style ?? styles.container} {...rest} />`, and the rewrite substitutes a styled twin for that `View`, so the class resolves. The Android one renders `specs/RNGestureHandlerRootViewNativeComponent` with no `react-native` specifier in the file, so the rewrite matches nothing: ANDROID/REWRITE {"type":"RNGestureHandlerRootView", "props":{"style":{"flex":1},"className":"w-80"}} That is the `PureNativeButton` shape on the export every app mounts at its root, on the platform where mounting it is mandatory. It type-checks today through `ViewProps`, so nothing stops a user writing it. A bare `className: "style"` mapping fixes Android and regresses the default: Gesture Handler reaches its `{ flex: 1 }` through `style ?? styles.container` over a module-private StyleSheet, and resolving a class is exactly a thing that makes `style` present. Measured, the class-only render went `[{width:95},{flex:1}]` to `[{width:95}]`. The wrapper carries the default itself and applies it on the same condition the `??` does — an inline style displaces it, a class does not. Jest's `defaultPlatform` is `ios`, so a suite cannot reach the Android variant by resolution. Substituting its module for the one the index requires puts the shipped wrapper over the shipped Android component; the new suite renders both variants and pins the byte-identical no-className path on each. Rendering the root view calls `maybeInitializeFabric`, which reaches an `install()` the JS module carries only inside a native binary, so the suites clear the global `isFabric()` reads. The no-className guard compares `JSON.stringify` of two trees, and that drops exactly the props whose value is a function — these nodes carry up to four. A wrapper written `const { onPress, ...rest } = props` reddened nothing: 3 failed, 1170 passed, byte-for-byte the control. `Object.keys().sort()` does not close it either, because Pressable renders `testOnly_onPress={props.onPress}` whether or not one was given, so only the value goes `undefined`. Neither does firing a press: `findEventHandler` walks `element.parent` until something carries a prop matching the event, and the JSX element the test itself wrote is on that path, so a component that drops `onPress` on the floor still answers with the caller's own callback. Comparing the function-valued prop names against the unwrapped twin is what sees it, and it is the exact complement of what the byte comparison sees. A test pins that a press is not a forwarding probe, so the closer that looks obvious is not written again. `DrawerLayoutAndroid` renders no handler at all under react-native's jest mock, which makes its generated case an equality between two empty sets. It is named rather than left to read as a measurement, and pinned beside the block. The Reanimated exclusion keeps its outcome and loses its reason. "Out of reach entirely" reads as an external constraint, and the constraint is this PR's own `moduleName ===` branch — a `startsWith` away, in a function that already carries a non-exact branch for react-native's Libraries. What actually blocks them is an API question: neither exposes a plain `style`, only `contentContainerStyle` / `drawerContainerStyle` and `containerStyle` / `childrenContainerStyle`, so covering them means minting four `*ClassName` props. Those are the same four `DrawerLayout` and `Swipeable` expose, which is what makes it worth revisiting: the deprecated bucket is excluded because Gesture Handler sends users to Pressable and to the Reanimated twins, and the twins are the uncovered set. The derivation's domain is `Object.keys` over the index module, so those two are outside it permanently — that is the limit on a census that otherwise enrols a new member on its own commit.
1 parent dac729a commit 29bdd9b

6 files changed

Lines changed: 372 additions & 13 deletions

‎src/__tests__/_gesture-handler.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ interface RenderedNode {
1111
children: RenderedNode[] | null;
1212
}
1313

14+
/**
15+
* `GestureHandlerRootView` calls `maybeInitializeFabric()` while rendering, which
16+
* reaches `RNGestureHandlerModule.install()` — a method the JS module carries only
17+
* inside a native binary. React Native's own jest setup defines `nativeFabricUIManager`
18+
* as `{}`, and `isFabric()` reads that global and nothing else, so clearing it takes
19+
* the branch that never touches the module. Every suite that renders the re-declared
20+
* census needs this, which is why it is here rather than in one of them.
21+
*/
22+
export function disableFabric(): void {
23+
Reflect.set(globalThis, "nativeFabricUIManager", undefined);
24+
}
25+
1426
/** Every rendered element's props, at any depth. */
1527
export function collectProps(node: unknown): Record<string, unknown>[] {
1628
if (node === null || typeof node !== "object") {
@@ -26,6 +38,26 @@ export function collectProps(node: unknown): Record<string, unknown>[] {
2638
return [props, ...collectProps(children)];
2739
}
2840

41+
/**
42+
* Every rendered element's function-valued prop names, at any depth. `JSON.stringify`
43+
* drops exactly the props whose value is a function, so a byte comparison of two trees
44+
* is blind to a wrapper that swallows a handler — and these nodes carry up to four.
45+
* This is the complement of what the byte comparison sees, which is what makes the two
46+
* together a whole guard.
47+
*
48+
* The value TYPE is the discriminator, not the name. Gesture Handler's Pressable renders
49+
* `testOnly_onPress={props.onPress}` unconditionally, so the KEY is present either way
50+
* and `Object.keys()` reports no difference at all; only the value goes `undefined`.
51+
*/
52+
export function functionPropNames(node: unknown): string[][] {
53+
return collectProps(node).map((props) =>
54+
Object.entries(props)
55+
.filter(([, value]) => typeof value === "function")
56+
.map(([name]) => name)
57+
.sort(),
58+
);
59+
}
60+
2961
/**
3062
* Every style object in the tree, at any array depth. The merge nests — a Pressable
3163
* carrying both `className` and `style` renders `[{}, [{…}, {…}]]` — so flattening a
@@ -48,7 +80,7 @@ export function flattenStyles(node: unknown): Record<string, unknown>[] {
4880
/**
4981
* Derived from the module, not restated: a member the wrapper re-declares is one whose
5082
* export is no longer the one `export *` provided. Every generated case reads this, so
51-
* an eighth re-declaration is covered the moment it lands.
83+
* a further re-declaration is covered the moment it lands.
5284
*/
5385
export function deriveReDeclared(
5486
styledExports: Record<string, unknown>,
@@ -69,7 +101,6 @@ export const notAComponent = [
69101
"Directions",
70102
"Gesture",
71103
"GestureDetector",
72-
"GestureHandlerRootView",
73104
"HoverEffect",
74105
"MouseButton",
75106
"PointerType",
@@ -128,6 +159,11 @@ export const reasonedExclusions = [
128159
* when a member has been missed, and the drop invariant is generated from THIS — so
129160
* an unhandled component is rendered and held to the invariant rather than waiting
130161
* for the accounting test to notice a name is absent from a list.
162+
*
163+
* The domain is `Object.keys` over the index module, and that is the limit of what
164+
* deriving buys: ReanimatedDrawerLayout and ReanimatedSwipeable ship from their own
165+
* entry points, so they are outside it permanently and no upstream change can enrol
166+
* them here. Covering those two is an edit to this file, not a thing it notices.
131167
*/
132168
export function deriveExcludedComponents(
133169
gestureHandlerExports: Record<string, unknown>,
@@ -140,6 +176,17 @@ export function deriveExcludedComponents(
140176
.sort();
141177
}
142178

179+
/**
180+
* Re-declared members that render no function-valued prop for the guard to compare, so
181+
* its verdict on them is an equality between two empty sets. React Native's jest mock for
182+
* the Android-only DrawerLayoutAndroid renders a debug placeholder `View` and forwards
183+
* none of its props — not `testID`, not a callback — which is the same tier limit the
184+
* RefreshControl exclusion stands on. Naming it is what stops the generated case reading
185+
* as a measurement it is not, and the pinned test beside the block is what keeps the
186+
* name honest.
187+
*/
188+
export const handlerUnobservable = ["DrawerLayoutAndroid"];
189+
143190
/** Props a component will not render at all without. */
144191
export const requiredProps: Record<string, Record<string, unknown>> = {
145192
DrawerLayout: { renderNavigationView: () => null },

‎src/__tests__/compiler/react-native-gesture-handler.test.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import * as RNGH from "react-native-gesture-handler";
99

1010
import {
1111
deriveReDeclared,
12+
disableFabric,
1213
flattenStyles,
1314
requiredProps,
1415
} from "../_gesture-handler";
1516

17+
beforeAll(disableFabric);
18+
1619
/**
1720
* The compiler plane's statement about the gesture-handler defect is that it has none:
1821
* `compile` takes a CSS string and nothing else, so no artifact it emits can know which
@@ -70,8 +73,8 @@ test("the declaration carries a style object, not a prop target", () => {
7073
});
7174

7275
test("the census the two runtime halves are generated from is non-empty", () => {
73-
// Derived by diffing the wrapper's exports against the real package's, so an
74-
// eighth re-declaration joins both halves on its own. An emptied census would
76+
// Derived by diffing the wrapper's exports against the real package's, so a
77+
// further re-declaration joins both halves on its own. An emptied census would
7578
// make every case below vacuous.
7679
expect(reDeclared.length).toBeGreaterThan(0);
7780
});

‎src/__tests__/native/react-native-gesture-handler-rewrite.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import { registerCSS, testID } from "react-native-css/jest";
66
import {
77
collectProps,
88
deprecatedByGestureHandler,
9+
disableFabric,
910
flattenStyles,
1011
reachedByTheRewrite,
1112
requiredProps,
1213
} from "../_gesture-handler";
1314

15+
beforeAll(disableFabric);
16+
1417
/**
1518
* `nativeResolver` rewrites every `react-native` import outside this package to
1619
* `react-native-css/components` — react-native's own exports with the styled
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { ComponentType } from "react";
2+
3+
import { render } from "@testing-library/react-native";
4+
import { registerCSS, testID } from "react-native-css/jest";
5+
6+
import {
7+
collectProps,
8+
disableFabric,
9+
flattenStyles,
10+
} from "../_gesture-handler";
11+
12+
/**
13+
* `GestureHandlerRootView` has two implementations, and only one of them is reachable
14+
* from the sibling suites. The default renders a react-native `View`, so the rewrite
15+
* substitutes a styled twin underneath it and the class resolves whether or not this
16+
* package re-declares anything. The Android one renders
17+
* `specs/RNGestureHandlerRootViewNativeComponent` — a codegen'd native view, with no
18+
* `react-native` specifier anywhere in the file — so the rewrite has nothing to match
19+
* and the raw class string lands on the element. That is the `PureNativeButton` shape,
20+
* on the export every app mounts at its root, on the platform where mounting it is
21+
* mandatory.
22+
*
23+
* Jest's `defaultPlatform` is `ios`, so the sibling suites resolve the default variant
24+
* and cannot see this. Substituting the Android module for the one the index requires
25+
* is what puts the shipped wrapper over the shipped Android component.
26+
*/
27+
jest.mock(
28+
"react-native-gesture-handler/lib/commonjs/components/GestureHandlerRootView",
29+
(): Record<string, unknown> =>
30+
jest.requireActual<Record<string, unknown>>(
31+
"react-native-gesture-handler/lib/commonjs/components/GestureHandlerRootView.android",
32+
),
33+
);
34+
35+
beforeAll(disableFabric);
36+
37+
function styledRootView(): ComponentType<Record<string, unknown>> {
38+
return jest.requireActual<Record<string, unknown>>(
39+
"react-native-css/components/react-native-gesture-handler",
40+
).GestureHandlerRootView as ComponentType<Record<string, unknown>>;
41+
}
42+
43+
function unwrappedRootView(): ComponentType<Record<string, unknown>> {
44+
return jest.requireActual<Record<string, unknown>>(
45+
"react-native-gesture-handler",
46+
).GestureHandlerRootView as ComponentType<Record<string, unknown>>;
47+
}
48+
49+
function renderWith(
50+
component: ComponentType<Record<string, unknown>>,
51+
props: Record<string, unknown> = {},
52+
): unknown {
53+
const Component = component;
54+
55+
return render(<Component testID={testID} {...props} />).toJSON();
56+
}
57+
58+
test("the Android variant is what these cases render", () => {
59+
// Without the substitution every assertion below measures the default variant, which
60+
// the sibling suites already cover — and the two disagree precisely here.
61+
expect(JSON.stringify(renderWith(unwrappedRootView()))).toContain(
62+
"RNGestureHandlerRootView",
63+
);
64+
});
65+
66+
test("the unwrapped Android variant leaks the class — the bug this closes", () => {
67+
registerCSS(`.w-97 { width: 97px; }`);
68+
69+
const tree = renderWith(unwrappedRootView(), { className: "w-97" });
70+
71+
expect(
72+
collectProps(tree).filter((props) => Object.hasOwn(props, "className")),
73+
).not.toEqual([]);
74+
expect(flattenStyles(tree)).not.toContainEqual(
75+
expect.objectContaining({ width: 97 }),
76+
);
77+
});
78+
79+
test("the wrapper resolves the class on Android, where the rewrite cannot", () => {
80+
registerCSS(`.w-98 { width: 98px; }`);
81+
82+
const tree = renderWith(styledRootView(), { className: "w-98" });
83+
84+
expect(flattenStyles(tree)).toContainEqual(
85+
expect.objectContaining({ width: 98 }),
86+
);
87+
88+
for (const props of collectProps(tree)) {
89+
expect(props).not.toHaveProperty("className");
90+
}
91+
});
92+
93+
test("the flex:1 default survives the class on Android too", () => {
94+
registerCSS(`.w-99 { width: 99px; }`);
95+
96+
expect(
97+
flattenStyles(renderWith(styledRootView(), { className: "w-99" })),
98+
).toContainEqual(expect.objectContaining({ flex: 1 }));
99+
});
100+
101+
test("renders identically to the unwrapped Android variant with no className", () => {
102+
// The resolver routes every gesture-handler import in the graph through this module,
103+
// so the no-className path has to stay byte-identical on both variants.
104+
expect(JSON.stringify(renderWith(styledRootView()))).toBe(
105+
JSON.stringify(renderWith(unwrappedRootView())),
106+
);
107+
});

0 commit comments

Comments
 (0)