Skip to content

Commit be3bfa6

Browse files
authored
[Flight] Basic Integration Test (#17307)
* [Flight] Basic Integration Test * Just act() * Lint * Remove unnecessary acts * Use Concurrent Mode * it.experimental * Fix prod test by advancing time * Don't observe initial state
1 parent 182f64f commit be3bfa6

File tree

1 file changed

+311
-0
lines changed

1 file changed

+311
-0
lines changed
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
*/
9+
10+
'use strict';
11+
12+
// Polyfills for test environment
13+
global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
14+
global.TextDecoder = require('util').TextDecoder;
15+
16+
// Don't wait before processing work on the server.
17+
// TODO: we can replace this with FlightServer.act().
18+
global.setImmediate = cb => cb();
19+
20+
let act;
21+
let Stream;
22+
let React;
23+
let ReactDOM;
24+
let ReactFlightDOMServer;
25+
let ReactFlightDOMClient;
26+
27+
describe('ReactFlightIntegration', () => {
28+
beforeEach(() => {
29+
jest.resetModules();
30+
act = require('react-dom/test-utils').act;
31+
Stream = require('stream');
32+
React = require('react');
33+
ReactDOM = require('react-dom');
34+
ReactFlightDOMServer = require('react-dom/unstable-flight-server');
35+
ReactFlightDOMClient = require('react-dom/unstable-flight-client');
36+
});
37+
38+
function getTestStream() {
39+
let writable = new Stream.PassThrough();
40+
let readable = new ReadableStream({
41+
start(controller) {
42+
writable.on('data', chunk => {
43+
controller.enqueue(chunk);
44+
});
45+
writable.on('end', () => {
46+
controller.close();
47+
});
48+
},
49+
});
50+
return {
51+
writable,
52+
readable,
53+
};
54+
}
55+
56+
it.experimental('should resolve the root', async () => {
57+
let {Suspense} = React;
58+
59+
// Model
60+
function Text({children}) {
61+
return <span>{children}</span>;
62+
}
63+
function HTML() {
64+
return (
65+
<div>
66+
<Text>hello</Text>
67+
<Text>world</Text>
68+
</div>
69+
);
70+
}
71+
function RootModel() {
72+
return {
73+
html: <HTML />,
74+
};
75+
}
76+
77+
// View
78+
function Message({result}) {
79+
return <p dangerouslySetInnerHTML={{__html: result.model.html}} />;
80+
}
81+
function App({result}) {
82+
return (
83+
<Suspense fallback={<h1>Loading...</h1>}>
84+
<Message result={result} />
85+
</Suspense>
86+
);
87+
}
88+
89+
let {writable, readable} = getTestStream();
90+
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
91+
let result = ReactFlightDOMClient.readFromReadableStream(readable);
92+
93+
let container = document.createElement('div');
94+
let root = ReactDOM.createRoot(container);
95+
await act(async () => {
96+
root.render(<App result={result} />);
97+
});
98+
expect(container.innerHTML).toBe(
99+
'<p><div><span>hello</span><span>world</span></div></p>',
100+
);
101+
});
102+
103+
it.experimental('should not get confused by $', async () => {
104+
let {Suspense} = React;
105+
106+
// Model
107+
function RootModel() {
108+
return {text: '$1'};
109+
}
110+
111+
// View
112+
function Message({result}) {
113+
return <p>{result.model.text}</p>;
114+
}
115+
function App({result}) {
116+
return (
117+
<Suspense fallback={<h1>Loading...</h1>}>
118+
<Message result={result} />
119+
</Suspense>
120+
);
121+
}
122+
123+
let {writable, readable} = getTestStream();
124+
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
125+
let result = ReactFlightDOMClient.readFromReadableStream(readable);
126+
127+
let container = document.createElement('div');
128+
let root = ReactDOM.createRoot(container);
129+
await act(async () => {
130+
root.render(<App result={result} />);
131+
});
132+
expect(container.innerHTML).toBe('<p>$1</p>');
133+
});
134+
135+
it.experimental('should progressively reveal chunks', async () => {
136+
let {Suspense} = React;
137+
138+
class ErrorBoundary extends React.Component {
139+
state = {hasError: false, error: null};
140+
static getDerivedStateFromError(error) {
141+
return {
142+
hasError: true,
143+
error,
144+
};
145+
}
146+
render() {
147+
if (this.state.hasError) {
148+
return this.props.fallback(this.state.error);
149+
}
150+
return this.props.children;
151+
}
152+
}
153+
154+
// Model
155+
function Text({children}) {
156+
return children;
157+
}
158+
function makeDelayedText() {
159+
let error, _resolve, _reject;
160+
let promise = new Promise((resolve, reject) => {
161+
_resolve = () => {
162+
promise = null;
163+
resolve();
164+
};
165+
_reject = e => {
166+
error = e;
167+
promise = null;
168+
reject(e);
169+
};
170+
});
171+
function DelayedText({children}) {
172+
if (promise) {
173+
throw promise;
174+
}
175+
if (error) {
176+
throw error;
177+
}
178+
return <Text>{children}</Text>;
179+
}
180+
return [DelayedText, _resolve, _reject];
181+
}
182+
183+
const [FriendsModel, resolveFriendsModel] = makeDelayedText();
184+
const [NameModel, resolveNameModel] = makeDelayedText();
185+
const [PostsModel, resolvePostsModel] = makeDelayedText();
186+
const [PhotosModel, resolvePhotosModel] = makeDelayedText();
187+
const [GamesModel, , rejectGamesModel] = makeDelayedText();
188+
function ProfileMore() {
189+
return {
190+
avatar: <Text>:avatar:</Text>,
191+
friends: <FriendsModel>:friends:</FriendsModel>,
192+
posts: <PostsModel>:posts:</PostsModel>,
193+
games: <GamesModel>:games:</GamesModel>,
194+
};
195+
}
196+
function ProfileModel() {
197+
return {
198+
photos: <PhotosModel>:photos:</PhotosModel>,
199+
name: <NameModel>:name:</NameModel>,
200+
more: <ProfileMore />,
201+
};
202+
}
203+
204+
// View
205+
function ProfileDetails({result}) {
206+
return (
207+
<div>
208+
{result.model.name}
209+
{result.model.more.avatar}
210+
</div>
211+
);
212+
}
213+
function ProfileSidebar({result}) {
214+
return (
215+
<div>
216+
{result.model.photos}
217+
{result.model.more.friends}
218+
</div>
219+
);
220+
}
221+
function ProfilePosts({result}) {
222+
return <div>{result.model.more.posts}</div>;
223+
}
224+
function ProfileGames({result}) {
225+
return <div>{result.model.more.games}</div>;
226+
}
227+
function ProfilePage({result}) {
228+
return (
229+
<>
230+
<Suspense fallback={<p>(loading)</p>}>
231+
<ProfileDetails result={result} />
232+
<Suspense fallback={<p>(loading sidebar)</p>}>
233+
<ProfileSidebar result={result} />
234+
</Suspense>
235+
<Suspense fallback={<p>(loading posts)</p>}>
236+
<ProfilePosts result={result} />
237+
</Suspense>
238+
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
239+
<Suspense fallback={<p>(loading games)</p>}>
240+
<ProfileGames result={result} />
241+
</Suspense>
242+
</ErrorBoundary>
243+
</Suspense>
244+
</>
245+
);
246+
}
247+
248+
let {writable, readable} = getTestStream();
249+
ReactFlightDOMServer.pipeToNodeWritable(<ProfileModel />, writable);
250+
let result = ReactFlightDOMClient.readFromReadableStream(readable);
251+
252+
let container = document.createElement('div');
253+
let root = ReactDOM.createRoot(container);
254+
await act(async () => {
255+
root.render(<ProfilePage result={result} />);
256+
});
257+
expect(container.innerHTML).toBe('<p>(loading)</p>');
258+
259+
// This isn't enough to show anything.
260+
await act(async () => {
261+
resolveFriendsModel();
262+
});
263+
expect(container.innerHTML).toBe('<p>(loading)</p>');
264+
265+
// We can now show the details. Sidebar and posts are still loading.
266+
await act(async () => {
267+
resolveNameModel();
268+
});
269+
// Advance time enough to trigger a nested fallback.
270+
jest.advanceTimersByTime(500);
271+
expect(container.innerHTML).toBe(
272+
'<div>:name::avatar:</div>' +
273+
'<p>(loading sidebar)</p>' +
274+
'<p>(loading posts)</p>' +
275+
'<p>(loading games)</p>',
276+
);
277+
278+
// Let's *fail* loading games.
279+
await act(async () => {
280+
rejectGamesModel(new Error('Game over'));
281+
});
282+
expect(container.innerHTML).toBe(
283+
'<div>:name::avatar:</div>' +
284+
'<p>(loading sidebar)</p>' +
285+
'<p>(loading posts)</p>' +
286+
'<p>Game over</p>', // TODO: should not have message in prod.
287+
);
288+
289+
// We can now show the sidebar.
290+
await act(async () => {
291+
resolvePhotosModel();
292+
});
293+
expect(container.innerHTML).toBe(
294+
'<div>:name::avatar:</div>' +
295+
'<div>:photos::friends:</div>' +
296+
'<p>(loading posts)</p>' +
297+
'<p>Game over</p>', // TODO: should not have message in prod.
298+
);
299+
300+
// Show everything.
301+
await act(async () => {
302+
resolvePostsModel();
303+
});
304+
expect(container.innerHTML).toBe(
305+
'<div>:name::avatar:</div>' +
306+
'<div>:photos::friends:</div>' +
307+
'<div>:posts:</div>' +
308+
'<p>Game over</p>', // TODO: should not have message in prod.
309+
);
310+
});
311+
});

0 commit comments

Comments
 (0)