Skip to content

Commit cd4f1ef

Browse files
feat: realistic instance hiding (#1898)
1 parent e3a3f68 commit cd4f1ef

2 files changed

Lines changed: 246 additions & 0 deletions

File tree

src/__tests__/render.test.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@ import { Text, View } from 'react-native';
44
import { render, screen } from '..';
55
import { _console, logger } from '../helpers/logger';
66

7+
function MaybeSuspend({
8+
children,
9+
promise,
10+
suspend,
11+
}: {
12+
children: React.ReactNode;
13+
promise: Promise<unknown>;
14+
suspend: boolean;
15+
}) {
16+
if (suspend) {
17+
React.use(promise);
18+
}
19+
20+
return children;
21+
}
22+
23+
function TestSuspenseWrapper({
24+
children,
25+
promise,
26+
suspend,
27+
}: {
28+
children: React.ReactNode;
29+
promise: Promise<unknown>;
30+
suspend: boolean;
31+
}) {
32+
return (
33+
<React.Suspense fallback={<Text>Loading...</Text>}>
34+
<MaybeSuspend promise={promise} suspend={suspend}>
35+
{children}
36+
</MaybeSuspend>
37+
</React.Suspense>
38+
);
39+
}
40+
741
test('renders a simple component', async () => {
842
const TestComponent = () => (
943
<View testID="container">
@@ -77,6 +111,203 @@ describe('render options', () => {
77111
});
78112
});
79113

114+
describe('hidden instance props', () => {
115+
test('does not retain hidden UI when the component suspends on initial render', async () => {
116+
const promise = new Promise<unknown>(() => {});
117+
118+
await render(
119+
<TestSuspenseWrapper promise={promise} suspend>
120+
<View testID="hidden-target">
121+
<Text>Ready</Text>
122+
</View>
123+
</TestSuspenseWrapper>,
124+
);
125+
126+
expect(screen.getByText('Loading...')).toBeOnTheScreen();
127+
expect(screen.queryByTestId('hidden-target')).not.toBeOnTheScreen();
128+
expect(screen.queryByTestId('hidden-target', { includeHiddenElements: true })).toBeNull();
129+
expect(screen.toJSON()).toMatchInlineSnapshot(`
130+
<Text>
131+
Loading...
132+
</Text>
133+
`);
134+
});
135+
136+
test('sets hidden suspended elements with no style to display none', async () => {
137+
const promise = new Promise<unknown>(() => {});
138+
139+
await render(
140+
<TestSuspenseWrapper promise={promise} suspend={false}>
141+
<View testID="hidden-target">
142+
<Text>Ready</Text>
143+
</View>
144+
</TestSuspenseWrapper>,
145+
);
146+
147+
expect(screen.getByText('Ready')).toBeOnTheScreen();
148+
149+
await screen.rerender(
150+
<TestSuspenseWrapper promise={promise} suspend>
151+
<View testID="hidden-target">
152+
<Text>Ready</Text>
153+
</View>
154+
</TestSuspenseWrapper>,
155+
);
156+
157+
expect(screen.getByText('Loading...')).toBeOnTheScreen();
158+
expect(
159+
screen.getByTestId('hidden-target', { includeHiddenElements: true }).props.style,
160+
).toEqual({
161+
display: 'none',
162+
});
163+
expect(screen.toJSON()).toMatchInlineSnapshot(`
164+
<>
165+
<View
166+
style={
167+
{
168+
"display": "none",
169+
}
170+
}
171+
testID="hidden-target"
172+
>
173+
<Text>
174+
Ready
175+
</Text>
176+
</View>
177+
<Text>
178+
Loading...
179+
</Text>
180+
</>
181+
`);
182+
});
183+
184+
test('appends display none when suspending an element with existing style', async () => {
185+
const promise = new Promise<unknown>(() => {});
186+
187+
await render(
188+
<TestSuspenseWrapper promise={promise} suspend={false}>
189+
<View style={{ opacity: 0.5 }} testID="hidden-target">
190+
<Text>Ready</Text>
191+
</View>
192+
</TestSuspenseWrapper>,
193+
);
194+
195+
expect(screen.getByText('Ready')).toBeOnTheScreen();
196+
197+
await screen.rerender(
198+
<TestSuspenseWrapper promise={promise} suspend>
199+
<View style={{ opacity: 0.5 }} testID="hidden-target">
200+
<Text>Ready</Text>
201+
</View>
202+
</TestSuspenseWrapper>,
203+
);
204+
205+
expect(screen.getByText('Loading...')).toBeOnTheScreen();
206+
expect(
207+
screen.getByTestId('hidden-target', { includeHiddenElements: true }).props.style,
208+
).toEqual([{ opacity: 0.5 }, { display: 'none' }]);
209+
expect(screen.toJSON()).toMatchInlineSnapshot(`
210+
<>
211+
<View
212+
style={
213+
[
214+
{
215+
"opacity": 0.5,
216+
},
217+
{
218+
"display": "none",
219+
},
220+
]
221+
}
222+
testID="hidden-target"
223+
>
224+
<Text>
225+
Ready
226+
</Text>
227+
</View>
228+
<Text>
229+
Loading...
230+
</Text>
231+
</>
232+
`);
233+
});
234+
235+
test('applies hidden styles to multiple direct child views when suspending', async () => {
236+
const promise = new Promise<unknown>(() => {});
237+
238+
await render(
239+
<TestSuspenseWrapper promise={promise} suspend={false}>
240+
<View testID="hidden-target-1">
241+
<Text>First</Text>
242+
</View>
243+
<View style={{ opacity: 0.5 }} testID="hidden-target-2">
244+
<Text>Second</Text>
245+
</View>
246+
</TestSuspenseWrapper>,
247+
);
248+
249+
expect(screen.getByText('First')).toBeOnTheScreen();
250+
expect(screen.getByText('Second')).toBeOnTheScreen();
251+
252+
await screen.rerender(
253+
<TestSuspenseWrapper promise={promise} suspend>
254+
<View testID="hidden-target-1">
255+
<Text>First</Text>
256+
</View>
257+
<View style={{ opacity: 0.5 }} testID="hidden-target-2">
258+
<Text>Second</Text>
259+
</View>
260+
</TestSuspenseWrapper>,
261+
);
262+
263+
expect(screen.getByText('Loading...')).toBeOnTheScreen();
264+
expect(
265+
screen.getByTestId('hidden-target-1', { includeHiddenElements: true }).props.style,
266+
).toEqual({
267+
display: 'none',
268+
});
269+
expect(
270+
screen.getByTestId('hidden-target-2', { includeHiddenElements: true }).props.style,
271+
).toEqual([{ opacity: 0.5 }, { display: 'none' }]);
272+
expect(screen.toJSON()).toMatchInlineSnapshot(`
273+
<>
274+
<View
275+
style={
276+
{
277+
"display": "none",
278+
}
279+
}
280+
testID="hidden-target-1"
281+
>
282+
<Text>
283+
First
284+
</Text>
285+
</View>
286+
<View
287+
style={
288+
[
289+
{
290+
"opacity": 0.5,
291+
},
292+
{
293+
"display": "none",
294+
},
295+
]
296+
}
297+
testID="hidden-target-2"
298+
>
299+
<Text>
300+
Second
301+
</Text>
302+
</View>
303+
<Text>
304+
Loading...
305+
</Text>
306+
</>
307+
`);
308+
});
309+
});
310+
80311
describe('component rendering', () => {
81312
test('render accepts RCTText component', async () => {
82313
await render(React.createElement('RCTText', { testID: 'text' }, 'Hello'));

src/render.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import type { StyleProp } from 'react-native';
23
import {
34
createRoot,
45
type HostElement,
@@ -39,6 +40,10 @@ export async function render<T>(element: React.ReactElement<T>, options: RenderO
3940
const rendererOptions: RootOptions = {
4041
textComponentTypes: HOST_TEXT_NAMES,
4142
publicTextComponentTypes: ['Text'],
43+
transformHiddenInstanceProps: ({ props }) => ({
44+
...props,
45+
style: withHiddenStyle(props.style as StyleProp<StyleLike>),
46+
}),
4247
};
4348

4449
const wrap = (element: React.ReactElement) => (Wrapper ? <Wrapper>{element}</Wrapper> : element);
@@ -117,3 +122,13 @@ function makeDebug(renderer: Root): DebugFunction {
117122
}
118123
return debugImpl;
119124
}
125+
126+
type StyleLike = Record<string, unknown>;
127+
128+
function withHiddenStyle(style: StyleProp<StyleLike>): StyleProp<StyleLike> {
129+
if (style == null) {
130+
return { display: 'none' };
131+
}
132+
133+
return [style, { display: 'none' }];
134+
}

0 commit comments

Comments
 (0)