Skip to content

Commit 98ec401

Browse files
authored
feat: add support for UI components (#17)
This pull request introduces basic support for rendering and interacting with UI views using an RNTL-like API. There's no way to interact with components yet, but you can test how props are handled.
1 parent 3f948eb commit 98ec401

File tree

13 files changed

+602
-9
lines changed

13 files changed

+602
-9
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
render,
6+
fn,
7+
waitFor,
8+
} from 'react-native-harness';
9+
import React, { useEffect } from 'react';
10+
import { View, Text } from 'react-native';
11+
12+
type TestComponentProps = {
13+
children?: React.ReactNode;
14+
onMount?: () => void;
15+
onUnmount?: () => void;
16+
};
17+
18+
const TestComponent = ({
19+
children,
20+
onMount,
21+
onUnmount,
22+
}: TestComponentProps) => {
23+
useEffect(() => {
24+
onMount?.();
25+
return () => {
26+
onUnmount?.();
27+
};
28+
}, [onMount, onUnmount]);
29+
30+
return <View>{children}</View>;
31+
};
32+
33+
describe('render', () => {
34+
it('should mount component when render is called', async () => {
35+
const onMount = fn();
36+
const onUnmount = fn();
37+
38+
const { unmount } = await render(
39+
<TestComponent onMount={onMount} onUnmount={onUnmount}>
40+
<Text>Test</Text>
41+
</TestComponent>
42+
);
43+
44+
expect(onMount).toHaveBeenCalledTimes(1);
45+
expect(onUnmount).toHaveBeenCalledTimes(0);
46+
47+
unmount();
48+
});
49+
50+
it('should unmount component when unmount is called', async () => {
51+
const onMount = fn();
52+
const onUnmount = fn();
53+
54+
const { unmount } = await render(
55+
<TestComponent onMount={onMount} onUnmount={onUnmount}>
56+
<Text>Test</Text>
57+
</TestComponent>
58+
);
59+
60+
expect(onMount).toHaveBeenCalledTimes(1);
61+
expect(onUnmount).toHaveBeenCalledTimes(0);
62+
63+
unmount();
64+
65+
await waitFor(() => {
66+
expect(onUnmount).toHaveBeenCalledTimes(1);
67+
});
68+
});
69+
70+
it('should not remount component when rerender is called', async () => {
71+
const onMount = fn();
72+
const onUnmount = fn();
73+
74+
const { rerender } = await render(
75+
<TestComponent onMount={onMount} onUnmount={onUnmount}>
76+
<Text>Initial</Text>
77+
</TestComponent>
78+
);
79+
80+
expect(onMount).toHaveBeenCalledTimes(1);
81+
expect(onUnmount).toHaveBeenCalledTimes(0);
82+
83+
await rerender(
84+
<TestComponent onMount={onMount} onUnmount={onUnmount}>
85+
<Text>Updated</Text>
86+
</TestComponent>
87+
);
88+
89+
expect(onMount).toHaveBeenCalledTimes(1);
90+
expect(onUnmount).toHaveBeenCalledTimes(0);
91+
});
92+
93+
it('should mount wrapper when render with wrapper is called', async () => {
94+
const onWrapperMount = fn();
95+
const onWrapperUnmount = fn();
96+
97+
const { unmount } = await render(<Text>Child</Text>, {
98+
wrapper: ({ children }) => (
99+
<TestComponent onMount={onWrapperMount} onUnmount={onWrapperUnmount}>
100+
{children}
101+
</TestComponent>
102+
),
103+
});
104+
105+
expect(onWrapperMount).toHaveBeenCalledTimes(1);
106+
expect(onWrapperUnmount).toHaveBeenCalledTimes(0);
107+
108+
unmount();
109+
});
110+
111+
it('should unmount wrapper when unmount is called', async () => {
112+
const onWrapperMount = fn();
113+
const onWrapperUnmount = fn();
114+
115+
const { unmount } = await render(<Text>Child</Text>, {
116+
wrapper: ({ children }) => (
117+
<TestComponent onMount={onWrapperMount} onUnmount={onWrapperUnmount}>
118+
{children}
119+
</TestComponent>
120+
),
121+
});
122+
123+
expect(onWrapperMount).toHaveBeenCalledTimes(1);
124+
125+
unmount();
126+
127+
await waitFor(() => {
128+
expect(onWrapperUnmount).toHaveBeenCalledTimes(1);
129+
});
130+
});
131+
132+
it('should not remount wrapper when component is rerendered', async () => {
133+
const onWrapperMount = fn();
134+
const onWrapperUnmount = fn();
135+
136+
const { rerender } = await render(<Text>Initial</Text>, {
137+
wrapper: ({ children }) => (
138+
<TestComponent onMount={onWrapperMount} onUnmount={onWrapperUnmount}>
139+
{children}
140+
</TestComponent>
141+
),
142+
});
143+
144+
expect(onWrapperMount).toHaveBeenCalledTimes(1);
145+
146+
await rerender(<Text>Updated</Text>);
147+
148+
expect(onWrapperMount).toHaveBeenCalledTimes(1);
149+
expect(onWrapperUnmount).toHaveBeenCalledTimes(0);
150+
});
151+
});

packages/runtime/src/client/factory.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { combineEventEmitters, EventEmitter } from '../utils/emitter.js';
1212
import { getWSServer } from './getWSServer.js';
1313
import { getBundler, evaluateModule, Bundler } from '../bundler/index.js';
1414
import { markTestsAsSkippedByName } from '../filtering/index.js';
15+
import { setup } from '../render/setup.js';
1516

1617
export const getClient = async () => {
1718
const client = await getBridgeClient(getWSServer(), {
@@ -119,10 +120,11 @@ export const getClient = async () => {
119120
}
120121

121122
const moduleJs = await bundler.getModule(path);
122-
const collectionResult = await collector.collect(
123-
() => evaluateModule(moduleJs, path),
124-
path
125-
);
123+
const collectionResult = await collector.collect(() => {
124+
// Setup automatic cleanup for rendered components
125+
setup();
126+
evaluateModule(moduleJs, path);
127+
}, path);
126128

127129
// Apply test name pattern by marking non-matching tests as skipped
128130
const processedTestSuite = options.testNamePattern

packages/runtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from './collector/index.js';
77
export * from './mocker/index.js';
88
export * from './namespace.js';
99
export * from './waitFor.js';
10+
export * from './render/index.js';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React from 'react';
2+
import { View, Text, StyleSheet, ScrollView } from 'react-native';
3+
4+
type ErrorBoundaryProps = {
5+
children: React.ReactNode;
6+
};
7+
8+
type ErrorBoundaryState = {
9+
hasError: boolean;
10+
error: Error | null;
11+
};
12+
13+
export class ErrorBoundary extends React.Component<
14+
ErrorBoundaryProps,
15+
ErrorBoundaryState
16+
> {
17+
constructor(props: ErrorBoundaryProps) {
18+
super(props);
19+
this.state = { hasError: false, error: null };
20+
}
21+
22+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
23+
return { hasError: true, error };
24+
}
25+
26+
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
27+
console.error('Error caught by ErrorBoundary:', error, errorInfo);
28+
}
29+
30+
override componentDidUpdate(prevProps: ErrorBoundaryProps): void {
31+
// Reset error state when children change (new component rendered)
32+
if (prevProps.children !== this.props.children && this.state.hasError) {
33+
this.setState({ hasError: false, error: null });
34+
}
35+
}
36+
37+
override render(): React.ReactNode {
38+
if (this.state.hasError && this.state.error) {
39+
return (
40+
<View style={styles.errorContainer}>
41+
<View style={styles.errorContent}>
42+
<Text style={styles.errorTitle}>Component Error</Text>
43+
<Text style={styles.errorSubtitle}>
44+
The rendered component threw an error:
45+
</Text>
46+
<ScrollView style={styles.errorScrollView}>
47+
<Text style={styles.errorMessage}>
48+
{this.state.error.message}
49+
</Text>
50+
{this.state.error.stack && (
51+
<Text style={styles.errorStack}>{this.state.error.stack}</Text>
52+
)}
53+
</ScrollView>
54+
</View>
55+
</View>
56+
);
57+
}
58+
59+
return this.props.children;
60+
}
61+
}
62+
63+
const styles = StyleSheet.create({
64+
errorContainer: {
65+
flex: 1,
66+
backgroundColor: 'rgba(220, 38, 38, 0.1)',
67+
justifyContent: 'center',
68+
alignItems: 'center',
69+
padding: 20,
70+
},
71+
errorContent: {
72+
backgroundColor: '#1f2937',
73+
borderRadius: 12,
74+
padding: 20,
75+
maxWidth: 500,
76+
width: '100%',
77+
maxHeight: '80%',
78+
borderWidth: 2,
79+
borderColor: '#dc2626',
80+
},
81+
errorTitle: {
82+
fontSize: 24,
83+
fontWeight: '700',
84+
color: '#dc2626',
85+
marginBottom: 8,
86+
},
87+
errorSubtitle: {
88+
fontSize: 14,
89+
color: '#9ca3af',
90+
marginBottom: 16,
91+
},
92+
errorScrollView: {
93+
maxHeight: 400,
94+
},
95+
errorMessage: {
96+
fontSize: 16,
97+
fontWeight: '600',
98+
color: '#fca5a5',
99+
marginBottom: 12,
100+
fontFamily: 'Courier',
101+
},
102+
errorStack: {
103+
fontSize: 12,
104+
color: '#d1d5db',
105+
fontFamily: 'Courier',
106+
lineHeight: 18,
107+
},
108+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React, { useEffect } from 'react';
2+
import { View, StyleSheet } from 'react-native';
3+
import { useRenderedElement } from '../ui/state.js';
4+
import { store } from '../ui/state.js';
5+
import { ErrorBoundary } from './ErrorBoundary.js';
6+
7+
export const TestComponentOverlay = (): React.ReactElement | null => {
8+
const { element, key } = useRenderedElement();
9+
10+
useEffect(() => {
11+
// Call onRenderCallback when element changes
12+
const callback = store.getState().onRenderCallback;
13+
14+
if (callback) {
15+
callback();
16+
store.getState().setOnRenderCallback(null);
17+
}
18+
}, [element]);
19+
20+
if (!element) {
21+
return null;
22+
}
23+
24+
const handleLayout = (): void => {
25+
const callback = store.getState().onLayoutCallback;
26+
27+
if (callback) {
28+
callback();
29+
// Clear the callback after calling it
30+
store.getState().setOnLayoutCallback(null);
31+
}
32+
};
33+
34+
return (
35+
<View key={key} style={styles.overlay} onLayout={handleLayout}>
36+
<ErrorBoundary>{element}</ErrorBoundary>
37+
</View>
38+
);
39+
};
40+
41+
const styles = StyleSheet.create({
42+
overlay: {
43+
...StyleSheet.absoluteFillObject,
44+
backgroundColor: '#0a1628',
45+
zIndex: 1000,
46+
},
47+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { store } from '../ui/state.js';
2+
3+
export const cleanup = (): void => {
4+
store.getState().setRenderedElement(null);
5+
store.getState().setOnLayoutCallback(null);
6+
store.getState().setOnRenderCallback(null);
7+
};

0 commit comments

Comments
 (0)