Skip to content

Commit 6431b95

Browse files
committed
classic fire event simulation using fibers
1 parent c5a2f3e commit 6431b95

24 files changed

+160
-129
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"release-it": "^18.0.0",
9494
"typescript": "^5.6.3",
9595
"typescript-eslint": "^8.19.1",
96-
"universal-test-renderer": "0.8.1"
96+
"universal-test-renderer": "0.8.2"
9797
},
9898
"publishConfig": {
9999
"registry": "https://registry.npmjs.org"

src/__tests__/fire-event.test.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,11 @@ describe('fireEvent', () => {
4949
expect(onPressMock).toHaveBeenCalled();
5050
});
5151

52-
test('should not fire if the press handler is not passed to children', () => {
52+
test('should fire if the press handler is not passed to children', () => {
5353
const onPressMock = jest.fn();
54-
render(
55-
// TODO: this functionality is buggy, i.e. it will fail if we wrap this component with a View.
56-
<WithoutEventComponent onPress={onPressMock} />,
57-
);
54+
render(<WithoutEventComponent onPress={onPressMock} />);
5855
fireEvent(screen.getByText('Without event'), 'press');
59-
expect(onPressMock).not.toHaveBeenCalled();
56+
expect(onPressMock).toHaveBeenCalled();
6057
});
6158
});
6259

src/event-handler.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,35 @@ export function getEventHandler(
3030
return undefined;
3131
}
3232

33+
export function getFiberEventHandler(
34+
element: HostElement['unstable_fiber'],
35+
eventName: string,
36+
options?: EventHandlerOptions,
37+
) {
38+
if (element === null || !element.memoizedProps) {
39+
return undefined;
40+
}
41+
42+
const handlerName = getEventHandlerName(eventName);
43+
if (typeof element.memoizedProps[handlerName] === 'function') {
44+
return element.memoizedProps[handlerName];
45+
}
46+
47+
if (options?.loose && typeof element.memoizedProps[eventName] === 'function') {
48+
return element.memoizedProps[eventName];
49+
}
50+
51+
if (typeof element.memoizedProps[`testOnly_${handlerName}`] === 'function') {
52+
return element.memoizedProps[`testOnly_${handlerName}`];
53+
}
54+
55+
if (options?.loose && typeof element.memoizedProps[`testOnly_${eventName}`] === 'function') {
56+
return element.memoizedProps[`testOnly_${eventName}`];
57+
}
58+
59+
return undefined;
60+
}
61+
3362
export function getEventHandlerName(eventName: string) {
3463
return `on${capitalizeFirstLetter(eventName)}`;
3564
}

src/fire-event.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ import type {
88
import type { HostElement } from 'universal-test-renderer';
99

1010
import act from './act';
11-
import { getEventHandler } from './event-handler';
11+
import { getEventHandler, getFiberEventHandler } from './event-handler';
1212
import { isElementMounted } from './helpers/component-tree';
1313
import { isHostScrollView, isHostTextInput } from './helpers/host-component-names';
1414
import { isPointerEventEnabled } from './helpers/pointer-events';
1515
import { isEditableTextInput } from './helpers/text-input';
1616
import { nativeState } from './native-state';
1717
import type { Point, StringWithAutocomplete } from './types';
18-
import { EventBuilder } from './user-event/event-builder';
1918

2019
type EventHandler = (...args: unknown[]) => unknown;
2120

@@ -86,19 +85,40 @@ function findEventHandler(
8685
): EventHandler | null {
8786
const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder;
8887

89-
const handler = getEventHandler(element, eventName, { loose: true });
88+
const handler =
89+
getEventHandler(element, eventName, { loose: true }) ??
90+
findEventHandlerFromFiber(element.unstable_fiber, eventName);
9091
if (handler && isEventEnabled(element, eventName, touchResponder)) {
9192
return handler;
9293
}
9394

94-
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
95-
if (element.parent === null || element.parent.parent === null) {
95+
if (element.parent === null) {
9696
return null;
9797
}
9898

9999
return findEventHandler(element.parent, eventName, touchResponder);
100100
}
101101

102+
type Fiber = HostElement['unstable_fiber'];
103+
104+
function findEventHandlerFromFiber(fiber: Fiber, eventName: string): EventHandler | null {
105+
if (fiber === null) {
106+
return null;
107+
}
108+
109+
const handler = getFiberEventHandler(fiber, eventName, { loose: true });
110+
if (handler) {
111+
return handler;
112+
}
113+
114+
// No parent fiber or we reached another host element
115+
if (fiber.return === null || typeof fiber.return.type === 'string') {
116+
return null;
117+
}
118+
119+
return findEventHandlerFromFiber(fiber.return, eventName);
120+
}
121+
102122
// String union type of keys of T that start with on, stripped of 'on'
103123
type EventNameExtractor<T> = keyof {
104124
[K in keyof T as K extends `on${infer Rest}` ? Uncapitalize<Rest> : never]: T[K];
@@ -132,37 +152,9 @@ function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[
132152
return returnValue;
133153
}
134154

135-
fireEvent.press = (element: HostElement, ...data: unknown[]) => {
136-
const nativeData =
137-
data.length === 1 &&
138-
typeof data[0] === 'object' &&
139-
data[0] !== null &&
140-
'nativeEvent' in data[0] &&
141-
typeof data[0].nativeEvent === 'object'
142-
? data[0].nativeEvent
143-
: null;
144-
145-
const responderGrantEvent = EventBuilder.Common.responderGrant();
146-
if (nativeData) {
147-
responderGrantEvent.nativeEvent = {
148-
...responderGrantEvent.nativeEvent,
149-
...nativeData,
150-
};
151-
}
152-
fireEvent(element, 'responderGrant', responderGrantEvent);
153-
155+
fireEvent.press = (element: HostElement, ...data: unknown[]) =>
154156
fireEvent(element, 'press', ...data);
155157

156-
const responderReleaseEvent = EventBuilder.Common.responderRelease();
157-
if (nativeData) {
158-
responderReleaseEvent.nativeEvent = {
159-
...responderReleaseEvent.nativeEvent,
160-
...nativeData,
161-
};
162-
}
163-
fireEvent(element, 'responderRelease', responderReleaseEvent);
164-
};
165-
166158
fireEvent.changeText = (element: HostElement, ...data: unknown[]) =>
167159
fireEvent(element, 'changeText', ...data);
168160

src/helpers/accessibility.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ export function computeAriaModal(element: HostElement): boolean | undefined {
157157
export function computeAriaLabel(element: HostElement): string | undefined {
158158
const labelElementId = element.props['aria-labelledby'] ?? element.props.accessibilityLabelledBy;
159159
if (labelElementId) {
160-
const rootElement = getContainerElement(element);
161-
const labelElement = findAll(rootElement, (node) => node.props.nativeID === labelElementId, {
160+
const container = getContainerElement(element);
161+
const labelElement = findAll(container, (node) => node.props.nativeID === labelElementId, {
162162
includeHiddenElements: true,
163163
});
164164
if (labelElement.length > 0) {

src/helpers/component-tree.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ import type { HostElement } from 'universal-test-renderer';
22

33
import { screen } from '../screen';
44

5+
/**
6+
* Checks if the given element is a host element.
7+
* @param element The element to check.
8+
*/
9+
export function isValidHostElement(element?: HostElement | null): element is HostElement {
10+
return typeof element?.type === 'string' && element.props !== null;
11+
}
12+
513
export function isElementMounted(element: HostElement) {
6-
return true; // getContainerElement(element) === screen.container;
14+
return getContainerElement(element) === screen.container;
715
}
816

917
/**

src/helpers/find-all.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { ContainerElement, HostElement } from 'universal-test-renderer';
2-
import { findAll as findAllInternal } from 'universal-test-renderer';
1+
import type { HostElement } from 'universal-test-renderer';
32

43
import { getConfig } from '../config';
54
import { isHiddenFromAccessibility } from './accessibility';
@@ -16,11 +15,11 @@ interface FindAllOptions {
1615
}
1716

1817
export function findAll(
19-
root: ContainerElement | HostElement,
18+
root: HostElement,
2019
predicate: (element: HostElement) => boolean,
2120
options?: FindAllOptions,
2221
): HostElement[] {
23-
const results = findAllInternal(root, predicate, options);
22+
const results = root.findAll(predicate, options);
2423

2524
const includeHiddenElements =
2625
options?.includeHiddenElements ?? options?.hidden ?? getConfig()?.defaultIncludeHiddenElements;

src/matchers/__tests__/to-be-visible.test.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,22 @@ test('toBeVisible() on null elements', () => {
237237
});
238238

239239
test('toBeVisible() on non-React elements', () => {
240-
expect(() =>
241-
expect({ name: 'Non-React element' }).not.toBeVisible(),
242-
).toThrowErrorMatchingInlineSnapshot(`"Cannot read properties of undefined (reading 'style')"`);
240+
expect(() => expect({ name: 'Non-React element' }).not.toBeVisible())
241+
.toThrowErrorMatchingInlineSnapshot(`
242+
"expect(received).not.toBeVisible()
243243
244-
expect(() => expect(true).not.toBeVisible()).toThrowErrorMatchingInlineSnapshot(
245-
`"Invalid value used as weak map key"`,
246-
);
244+
received value must be a host element.
245+
Received has type: object
246+
Received has value: {"name": "Non-React element"}"
247+
`);
248+
249+
expect(() => expect(true).not.toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
250+
"expect(received).not.toBeVisible()
251+
252+
received value must be a host element.
253+
Received has type: boolean
254+
Received has value: true"
255+
`);
247256
});
248257

249258
test('toBeVisible() does not throw on invalid style', () => {

src/matchers/__tests__/to-contain-element.test.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,22 @@ test('toContainElement() handles non-element container', () => {
100100

101101
const view = screen.getByTestId('view');
102102

103-
expect(() =>
104-
expect({ name: 'non-element' }).not.toContainElement(view),
105-
).toThrowErrorMatchingInlineSnapshot(`"Cannot read properties of undefined (reading 'forEach')"`);
103+
expect(() => expect({ name: 'non-element' }).not.toContainElement(view))
104+
.toThrowErrorMatchingInlineSnapshot(`
105+
"expect(received).not.toContainElement()
106106
107-
expect(() => expect(true).not.toContainElement(view)).toThrowErrorMatchingInlineSnapshot(
108-
`"Cannot read properties of undefined (reading 'forEach')"`,
109-
);
107+
received value must be a host element.
108+
Received has type: object
109+
Received has value: {"name": "non-element"}"
110+
`);
111+
112+
expect(() => expect(true).not.toContainElement(view)).toThrowErrorMatchingInlineSnapshot(`
113+
"expect(received).not.toContainElement()
114+
115+
received value must be a host element.
116+
Received has type: boolean
117+
Received has value: true"
118+
`);
110119
});
111120

112121
test('toContainElement() handles non-element element', () => {

src/matchers/__tests__/to-have-accessible-name.test.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,19 @@ test('toHaveAccessibleName() handles a view without name when called without exp
121121
it('toHaveAccessibleName() rejects non-host element', () => {
122122
const nonElement = 'This is not a HostElement';
123123

124-
expect(() => expect(nonElement).toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(
125-
`"Cannot read properties of undefined (reading 'aria-labelledby')"`,
126-
);
124+
expect(() => expect(nonElement).toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(`
125+
"expect(received).toHaveAccessibleName()
127126
128-
expect(() => expect(nonElement).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(
129-
`"Cannot read properties of undefined (reading 'aria-labelledby')"`,
130-
);
127+
received value must be a host element.
128+
Received has type: string
129+
Received has value: "This is not a HostElement""
130+
`);
131+
132+
expect(() => expect(nonElement).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(`
133+
"expect(received).not.toHaveAccessibleName()
134+
135+
received value must be a host element.
136+
Received has type: string
137+
Received has value: "This is not a HostElement""
138+
`);
131139
});

0 commit comments

Comments
 (0)