@samitouri / QOS-React-2 / commits / be3bfa6fab

[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

Dan Abramov committed Nov 9, 2019 at 02:56 UTC be3bfa6fabccd2e76d3bb3841a67f6d8ede7892c
1 file changed +311
packages/react-dom/src/__tests__/ReactFlightIntegration-test.js new
+311
@@ -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 +});