[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
+});