Skip to content

Commit f80745f

Browse files
authoredSep 19, 2022
Feat : emulate string outside text error (#1070)
1 parent 10e69c5 commit f80745f

File tree

5 files changed

+260
-34
lines changed

5 files changed

+260
-34
lines changed
 

‎src/__tests__/__snapshots__/render.test.tsx.snap

+9-21
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,15 @@ exports[`debug 1`] = `
3232
accessible={true}
3333
collapsable={false}
3434
focusable={true}
35+
onBlur={[Function onBlur]}
3536
onClick={[Function onClick]}
37+
onFocus={[Function onFocus]}
3638
onResponderGrant={[Function onResponderGrant]}
3739
onResponderMove={[Function onResponderMove]}
3840
onResponderRelease={[Function onResponderRelease]}
3941
onResponderTerminate={[Function onResponderTerminate]}
4042
onResponderTerminationRequest={[Function onResponderTerminationRequest]}
4143
onStartShouldSetResponder={[Function onStartShouldSetResponder]}
42-
style={
43-
Object {
44-
"opacity": 1,
45-
}
46-
}
4744
>
4845
<Text>
4946
Change freshness!
@@ -97,18 +94,15 @@ exports[`debug changing component: bananaFresh button message should now be "fre
9794
accessible={true}
9895
collapsable={false}
9996
focusable={true}
97+
onBlur={[Function onBlur]}
10098
onClick={[Function onClick]}
99+
onFocus={[Function onFocus]}
101100
onResponderGrant={[Function onResponderGrant]}
102101
onResponderMove={[Function onResponderMove]}
103102
onResponderRelease={[Function onResponderRelease]}
104103
onResponderTerminate={[Function onResponderTerminate]}
105104
onResponderTerminationRequest={[Function onResponderTerminationRequest]}
106105
onStartShouldSetResponder={[Function onStartShouldSetResponder]}
107-
style={
108-
Object {
109-
"opacity": 1,
110-
}
111-
}
112106
>
113107
<Text>
114108
Change freshness!
@@ -266,18 +260,15 @@ exports[`debug: with message 1`] = `
266260
accessible={true}
267261
collapsable={false}
268262
focusable={true}
263+
onBlur={[Function onBlur]}
269264
onClick={[Function onClick]}
265+
onFocus={[Function onFocus]}
270266
onResponderGrant={[Function onResponderGrant]}
271267
onResponderMove={[Function onResponderMove]}
272268
onResponderRelease={[Function onResponderRelease]}
273269
onResponderTerminate={[Function onResponderTerminate]}
274270
onResponderTerminationRequest={[Function onResponderTerminationRequest]}
275271
onStartShouldSetResponder={[Function onStartShouldSetResponder]}
276-
style={
277-
Object {
278-
"opacity": 1,
279-
}
280-
}
281272
>
282273
<Text>
283274
Change freshness!
@@ -303,19 +294,16 @@ exports[`toJSON 1`] = `
303294
<View
304295
accessible={true}
305296
collapsable={false}
306-
focusable={false}
297+
focusable={true}
298+
onBlur={[Function]}
307299
onClick={[Function]}
300+
onFocus={[Function]}
308301
onResponderGrant={[Function]}
309302
onResponderMove={[Function]}
310303
onResponderRelease={[Function]}
311304
onResponderTerminate={[Function]}
312305
onResponderTerminationRequest={[Function]}
313306
onStartShouldSetResponder={[Function]}
314-
style={
315-
{
316-
"opacity": 1,
317-
}
318-
}
319307
>
320308
<Text>
321309
press me
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import * as React from 'react';
2+
import { View, Text, Pressable } from 'react-native';
3+
import { render, fireEvent } from '..';
4+
5+
// eslint-disable-next-line no-console
6+
const originalConsoleError = console.error;
7+
8+
const VALIDATION_ERROR =
9+
'Invariant Violation: Text strings must be rendered within a <Text> component';
10+
const PROFILER_ERROR = 'The above error occurred in the <Profiler> component';
11+
12+
beforeEach(() => {
13+
// eslint-disable-next-line no-console
14+
console.error = (errorMessage: string) => {
15+
if (!errorMessage.includes(PROFILER_ERROR)) {
16+
originalConsoleError(errorMessage);
17+
}
18+
};
19+
});
20+
21+
afterEach(() => {
22+
// eslint-disable-next-line no-console
23+
console.error = originalConsoleError;
24+
});
25+
26+
test('should throw when rendering a string outside a text component', () => {
27+
expect(() =>
28+
render(<View>hello</View>, {
29+
unstable_validateStringsRenderedWithinText: true,
30+
})
31+
).toThrow(
32+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
33+
);
34+
});
35+
36+
test('should throw an error when rerendering with text outside of Text component', () => {
37+
const { rerender } = render(<View />, {
38+
unstable_validateStringsRenderedWithinText: true,
39+
});
40+
41+
expect(() => rerender(<View>hello</View>)).toThrow(
42+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
43+
);
44+
});
45+
46+
const InvalidTextAfterPress = () => {
47+
const [showText, setShowText] = React.useState(false);
48+
49+
if (!showText) {
50+
return (
51+
<Pressable onPress={() => setShowText(true)}>
52+
<Text>Show text</Text>
53+
</Pressable>
54+
);
55+
}
56+
57+
return <View>text rendered outside text component</View>;
58+
};
59+
60+
test('should throw an error when strings are rendered outside Text', () => {
61+
const { getByText } = render(<InvalidTextAfterPress />, {
62+
unstable_validateStringsRenderedWithinText: true,
63+
});
64+
65+
expect(() => fireEvent.press(getByText('Show text'))).toThrow(
66+
`${VALIDATION_ERROR}. Detected attempt to render "text rendered outside text component" string within a <View> component.`
67+
);
68+
});
69+
70+
test('should not throw for texts nested in fragments', () => {
71+
expect(() =>
72+
render(
73+
<Text>
74+
<>hello</>
75+
</Text>,
76+
{ unstable_validateStringsRenderedWithinText: true }
77+
)
78+
).not.toThrow();
79+
});
80+
81+
test('should not throw if option validateRenderedString is false', () => {
82+
expect(() => render(<View>hello</View>)).not.toThrow();
83+
});
84+
85+
test(`should throw when one of the children is a text and the parent is not a Text component`, () => {
86+
expect(() =>
87+
render(
88+
<View>
89+
<Text>hello</Text>
90+
hello
91+
</View>,
92+
{ unstable_validateStringsRenderedWithinText: true }
93+
)
94+
).toThrow(
95+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
96+
);
97+
});
98+
99+
test(`should throw when a string is rendered within a fragment rendered outside a Text`, () => {
100+
expect(() =>
101+
render(
102+
<View>
103+
<>hello</>
104+
</View>,
105+
{ unstable_validateStringsRenderedWithinText: true }
106+
)
107+
).toThrow(
108+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
109+
);
110+
});
111+
112+
test('should throw if a number is rendered outside a text', () => {
113+
expect(() =>
114+
render(<View>0</View>, { unstable_validateStringsRenderedWithinText: true })
115+
).toThrow(
116+
`${VALIDATION_ERROR}. Detected attempt to render "0" string within a <View> component.`
117+
);
118+
});
119+
120+
const Trans = ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>;
121+
122+
test('should throw with components returning string value not rendered in Text', () => {
123+
expect(() =>
124+
render(
125+
<View>
126+
<Trans i18nKey="hello" />
127+
</View>,
128+
{ unstable_validateStringsRenderedWithinText: true }
129+
)
130+
).toThrow(
131+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
132+
);
133+
});
134+
135+
test('should not throw with components returning string value rendered in Text', () => {
136+
expect(() =>
137+
render(
138+
<Text>
139+
<Trans i18nKey="hello" />
140+
</Text>,
141+
{ unstable_validateStringsRenderedWithinText: true }
142+
)
143+
).not.toThrow();
144+
});
145+
146+
test('should throw when rendering string in a View in a Text', () => {
147+
expect(() =>
148+
render(
149+
<Text>
150+
<View>hello</View>
151+
</Text>,
152+
{ unstable_validateStringsRenderedWithinText: true }
153+
)
154+
).toThrow(
155+
`${VALIDATION_ERROR}. Detected attempt to render "hello" string within a <View> component.`
156+
);
157+
});

‎src/__tests__/render.test.tsx

+3-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import * as React from 'react';
2-
import {
3-
View,
4-
Text,
5-
TextInput,
6-
TouchableOpacity,
7-
SafeAreaView,
8-
} from 'react-native';
2+
import { View, Text, TextInput, Pressable, SafeAreaView } from 'react-native';
93
import stripAnsi from 'strip-ansi';
104
import { render, fireEvent, RenderAPI } from '..';
115

@@ -21,9 +15,9 @@ const DEFAULT_INPUT_CUSTOMER = 'What banana?';
2115
class MyButton extends React.Component<any> {
2216
render() {
2317
return (
24-
<TouchableOpacity onPress={this.props.onPress}>
18+
<Pressable onPress={this.props.onPress}>
2519
<Text>{this.props.children}</Text>
26-
</TouchableOpacity>
20+
</Pressable>
2721
);
2822
}
2923
}

‎src/helpers/stringValidation.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ReactTestRendererNode } from 'react-test-renderer';
2+
3+
export const validateStringsRenderedWithinText = (
4+
rendererJSON: ReactTestRendererNode | Array<ReactTestRendererNode> | null
5+
) => {
6+
if (!rendererJSON) return;
7+
8+
if (Array.isArray(rendererJSON)) {
9+
rendererJSON.forEach(validateStringsRenderedWithinTextForNode);
10+
return;
11+
}
12+
13+
return validateStringsRenderedWithinTextForNode(rendererJSON);
14+
};
15+
16+
const validateStringsRenderedWithinTextForNode = (
17+
node: ReactTestRendererNode
18+
) => {
19+
if (typeof node === 'string') {
20+
return;
21+
}
22+
23+
if (node.type !== 'Text') {
24+
node.children?.forEach((child) => {
25+
if (typeof child === 'string') {
26+
throw new Error(
27+
`Invariant Violation: Text strings must be rendered within a <Text> component. Detected attempt to render "${child}" string within a <${node.type}> component.`
28+
);
29+
}
30+
});
31+
}
32+
33+
if (node.children) {
34+
node.children.forEach(validateStringsRenderedWithinTextForNode);
35+
}
36+
};

‎src/render.tsx

+55-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import TestRenderer from 'react-test-renderer';
22
import type { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer';
33
import * as React from 'react';
4+
import { Profiler } from 'react';
45
import act from './act';
56
import { addToCleanupQueue } from './cleanup';
67
import debugShallow from './helpers/debugShallow';
78
import debugDeep from './helpers/debugDeep';
89
import { getQueriesForElement } from './within';
9-
import { setRenderResult } from './screen';
10+
import { setRenderResult, screen } from './screen';
11+
import { validateStringsRenderedWithinText } from './helpers/stringValidation';
1012

1113
export type RenderOptions = {
1214
wrapper?: React.ComponentType<any>;
1315
createNodeMock?: (element: React.ReactElement) => any;
16+
unstable_validateStringsRenderedWithinText?: boolean;
1417
};
1518

1619
type TestRendererOptions = {
@@ -25,17 +28,65 @@ export type RenderResult = ReturnType<typeof render>;
2528
*/
2629
export default function render<T>(
2730
component: React.ReactElement<T>,
28-
{ wrapper: Wrapper, createNodeMock }: RenderOptions = {}
31+
{
32+
wrapper: Wrapper,
33+
createNodeMock,
34+
unstable_validateStringsRenderedWithinText,
35+
}: RenderOptions = {}
2936
) {
30-
const wrap = (innerElement: React.ReactElement) =>
31-
Wrapper ? <Wrapper>{innerElement}</Wrapper> : innerElement;
37+
if (unstable_validateStringsRenderedWithinText) {
38+
return renderWithStringValidation(component, {
39+
wrapper: Wrapper,
40+
createNodeMock,
41+
});
42+
}
43+
44+
const wrap = (element: React.ReactElement) =>
45+
Wrapper ? <Wrapper>{element}</Wrapper> : element;
3246

3347
const renderer = renderWithAct(
3448
wrap(component),
3549
createNodeMock ? { createNodeMock } : undefined
3650
);
51+
52+
return buildRenderResult(renderer, wrap);
53+
}
54+
55+
function renderWithStringValidation<T>(
56+
component: React.ReactElement<T>,
57+
{
58+
wrapper: Wrapper,
59+
createNodeMock,
60+
}: Omit<RenderOptions, 'unstable_validateStringsRenderedWithinText'> = {}
61+
) {
62+
const handleRender: React.ProfilerProps['onRender'] = (_, phase) => {
63+
if (phase === 'update') {
64+
validateStringsRenderedWithinText(screen.toJSON());
65+
}
66+
};
67+
68+
const wrap = (element: React.ReactElement) => (
69+
<Profiler id="renderProfiler" onRender={handleRender}>
70+
{Wrapper ? <Wrapper>{element}</Wrapper> : element}
71+
</Profiler>
72+
);
73+
74+
const renderer = renderWithAct(
75+
wrap(component),
76+
createNodeMock ? { createNodeMock } : undefined
77+
);
78+
validateStringsRenderedWithinText(renderer.toJSON());
79+
80+
return buildRenderResult(renderer, wrap);
81+
}
82+
83+
function buildRenderResult(
84+
renderer: ReactTestRenderer,
85+
wrap: (element: React.ReactElement) => JSX.Element
86+
) {
3787
const update = updateWithAct(renderer, wrap);
3888
const instance = renderer.root;
89+
3990
const unmount = () => {
4091
act(() => {
4192
renderer.unmount();

0 commit comments

Comments
 (0)
Please sign in to comment.