From be3bfa6fabccd2e76d3bb3841a67f6d8ede7892c Mon Sep 17 00:00:00 2001
From: Dan Abramov
Date: Sat, 9 Nov 2019 02:56:03 +0000
Subject: [PATCH] [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
---
.../__tests__/ReactFlightIntegration-test.js | 311 ++++++++++++++++++
1 file changed, 311 insertions(+)
create mode 100644 packages/react-dom/src/__tests__/ReactFlightIntegration-test.js
diff --git a/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js b/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js
new file mode 100644
index 0000000000000..7621513830464
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js
@@ -0,0 +1,311 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+// Polyfills for test environment
+global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
+global.TextDecoder = require('util').TextDecoder;
+
+// Don't wait before processing work on the server.
+// TODO: we can replace this with FlightServer.act().
+global.setImmediate = cb => cb();
+
+let act;
+let Stream;
+let React;
+let ReactDOM;
+let ReactFlightDOMServer;
+let ReactFlightDOMClient;
+
+describe('ReactFlightIntegration', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ act = require('react-dom/test-utils').act;
+ Stream = require('stream');
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactFlightDOMServer = require('react-dom/unstable-flight-server');
+ ReactFlightDOMClient = require('react-dom/unstable-flight-client');
+ });
+
+ function getTestStream() {
+ let writable = new Stream.PassThrough();
+ let readable = new ReadableStream({
+ start(controller) {
+ writable.on('data', chunk => {
+ controller.enqueue(chunk);
+ });
+ writable.on('end', () => {
+ controller.close();
+ });
+ },
+ });
+ return {
+ writable,
+ readable,
+ };
+ }
+
+ it.experimental('should resolve the root', async () => {
+ let {Suspense} = React;
+
+ // Model
+ function Text({children}) {
+ return {children};
+ }
+ function HTML() {
+ return (
+
+ hello
+ world
+
+ );
+ }
+ function RootModel() {
+ return {
+ html: ,
+ };
+ }
+
+ // View
+ function Message({result}) {
+ return
;
+ }
+ function App({result}) {
+ return (
+ Loading...}>
+
+
+ );
+ }
+
+ let {writable, readable} = getTestStream();
+ ReactFlightDOMServer.pipeToNodeWritable(, writable);
+ let result = ReactFlightDOMClient.readFromReadableStream(readable);
+
+ let container = document.createElement('div');
+ let root = ReactDOM.createRoot(container);
+ await act(async () => {
+ root.render();
+ });
+ expect(container.innerHTML).toBe(
+ 'helloworld
',
+ );
+ });
+
+ it.experimental('should not get confused by $', async () => {
+ let {Suspense} = React;
+
+ // Model
+ function RootModel() {
+ return {text: '$1'};
+ }
+
+ // View
+ function Message({result}) {
+ return {result.model.text}
;
+ }
+ function App({result}) {
+ return (
+ Loading...}>
+
+
+ );
+ }
+
+ let {writable, readable} = getTestStream();
+ ReactFlightDOMServer.pipeToNodeWritable(, writable);
+ let result = ReactFlightDOMClient.readFromReadableStream(readable);
+
+ let container = document.createElement('div');
+ let root = ReactDOM.createRoot(container);
+ await act(async () => {
+ root.render();
+ });
+ expect(container.innerHTML).toBe('$1
');
+ });
+
+ it.experimental('should progressively reveal chunks', async () => {
+ let {Suspense} = React;
+
+ class ErrorBoundary extends React.Component {
+ state = {hasError: false, error: null};
+ static getDerivedStateFromError(error) {
+ return {
+ hasError: true,
+ error,
+ };
+ }
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback(this.state.error);
+ }
+ return this.props.children;
+ }
+ }
+
+ // Model
+ function Text({children}) {
+ return children;
+ }
+ function makeDelayedText() {
+ let error, _resolve, _reject;
+ let promise = new Promise((resolve, reject) => {
+ _resolve = () => {
+ promise = null;
+ resolve();
+ };
+ _reject = e => {
+ error = e;
+ promise = null;
+ reject(e);
+ };
+ });
+ function DelayedText({children}) {
+ if (promise) {
+ throw promise;
+ }
+ if (error) {
+ throw error;
+ }
+ return {children};
+ }
+ return [DelayedText, _resolve, _reject];
+ }
+
+ const [FriendsModel, resolveFriendsModel] = makeDelayedText();
+ const [NameModel, resolveNameModel] = makeDelayedText();
+ const [PostsModel, resolvePostsModel] = makeDelayedText();
+ const [PhotosModel, resolvePhotosModel] = makeDelayedText();
+ const [GamesModel, , rejectGamesModel] = makeDelayedText();
+ function ProfileMore() {
+ return {
+ avatar: :avatar:,
+ friends: :friends:,
+ posts: :posts:,
+ games: :games:,
+ };
+ }
+ function ProfileModel() {
+ return {
+ photos: :photos:,
+ name: :name:,
+ more: ,
+ };
+ }
+
+ // View
+ function ProfileDetails({result}) {
+ return (
+
+ {result.model.name}
+ {result.model.more.avatar}
+
+ );
+ }
+ function ProfileSidebar({result}) {
+ return (
+
+ {result.model.photos}
+ {result.model.more.friends}
+
+ );
+ }
+ function ProfilePosts({result}) {
+ return {result.model.more.posts}
;
+ }
+ function ProfileGames({result}) {
+ return {result.model.more.games}
;
+ }
+ function ProfilePage({result}) {
+ return (
+ <>
+ (loading)}>
+
+ (loading sidebar)}>
+
+
+ (loading posts)}>
+
+
+ {e.message}
}>
+ (loading games)}>
+
+
+
+
+ >
+ );
+ }
+
+ let {writable, readable} = getTestStream();
+ ReactFlightDOMServer.pipeToNodeWritable(, writable);
+ let result = ReactFlightDOMClient.readFromReadableStream(readable);
+
+ let container = document.createElement('div');
+ let root = ReactDOM.createRoot(container);
+ await act(async () => {
+ root.render();
+ });
+ expect(container.innerHTML).toBe('(loading)
');
+
+ // This isn't enough to show anything.
+ await act(async () => {
+ resolveFriendsModel();
+ });
+ expect(container.innerHTML).toBe('(loading)
');
+
+ // We can now show the details. Sidebar and posts are still loading.
+ await act(async () => {
+ resolveNameModel();
+ });
+ // Advance time enough to trigger a nested fallback.
+ jest.advanceTimersByTime(500);
+ expect(container.innerHTML).toBe(
+ ':name::avatar:
' +
+ '(loading sidebar)
' +
+ '(loading posts)
' +
+ '(loading games)
',
+ );
+
+ // Let's *fail* loading games.
+ await act(async () => {
+ rejectGamesModel(new Error('Game over'));
+ });
+ expect(container.innerHTML).toBe(
+ ':name::avatar:
' +
+ '(loading sidebar)
' +
+ '(loading posts)
' +
+ 'Game over
', // TODO: should not have message in prod.
+ );
+
+ // We can now show the sidebar.
+ await act(async () => {
+ resolvePhotosModel();
+ });
+ expect(container.innerHTML).toBe(
+ ':name::avatar:
' +
+ ':photos::friends:
' +
+ '(loading posts)
' +
+ 'Game over
', // TODO: should not have message in prod.
+ );
+
+ // Show everything.
+ await act(async () => {
+ resolvePostsModel();
+ });
+ expect(container.innerHTML).toBe(
+ ':name::avatar:
' +
+ ':photos::friends:
' +
+ ':posts:
' +
+ 'Game over
', // TODO: should not have message in prod.
+ );
+ });
+});