mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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
This commit is contained in:
@@ -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 <span>{children}</span>;
|
||||
}
|
||||
function HTML() {
|
||||
return (
|
||||
<div>
|
||||
<Text>hello</Text>
|
||||
<Text>world</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function RootModel() {
|
||||
return {
|
||||
html: <HTML />,
|
||||
};
|
||||
}
|
||||
|
||||
// View
|
||||
function Message({result}) {
|
||||
return <p dangerouslySetInnerHTML={{__html: result.model.html}} />;
|
||||
}
|
||||
function App({result}) {
|
||||
return (
|
||||
<Suspense fallback={<h1>Loading...</h1>}>
|
||||
<Message result={result} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
let {writable, readable} = getTestStream();
|
||||
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
|
||||
let result = ReactFlightDOMClient.readFromReadableStream(readable);
|
||||
|
||||
let container = document.createElement('div');
|
||||
let root = ReactDOM.createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<App result={result} />);
|
||||
});
|
||||
expect(container.innerHTML).toBe(
|
||||
'<p><div><span>hello</span><span>world</span></div></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it.experimental('should not get confused by $', async () => {
|
||||
let {Suspense} = React;
|
||||
|
||||
// Model
|
||||
function RootModel() {
|
||||
return {text: '$1'};
|
||||
}
|
||||
|
||||
// View
|
||||
function Message({result}) {
|
||||
return <p>{result.model.text}</p>;
|
||||
}
|
||||
function App({result}) {
|
||||
return (
|
||||
<Suspense fallback={<h1>Loading...</h1>}>
|
||||
<Message result={result} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
let {writable, readable} = getTestStream();
|
||||
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
|
||||
let result = ReactFlightDOMClient.readFromReadableStream(readable);
|
||||
|
||||
let container = document.createElement('div');
|
||||
let root = ReactDOM.createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<App result={result} />);
|
||||
});
|
||||
expect(container.innerHTML).toBe('<p>$1</p>');
|
||||
});
|
||||
|
||||
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 <Text>{children}</Text>;
|
||||
}
|
||||
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: <Text>:avatar:</Text>,
|
||||
friends: <FriendsModel>:friends:</FriendsModel>,
|
||||
posts: <PostsModel>:posts:</PostsModel>,
|
||||
games: <GamesModel>:games:</GamesModel>,
|
||||
};
|
||||
}
|
||||
function ProfileModel() {
|
||||
return {
|
||||
photos: <PhotosModel>:photos:</PhotosModel>,
|
||||
name: <NameModel>:name:</NameModel>,
|
||||
more: <ProfileMore />,
|
||||
};
|
||||
}
|
||||
|
||||
// View
|
||||
function ProfileDetails({result}) {
|
||||
return (
|
||||
<div>
|
||||
{result.model.name}
|
||||
{result.model.more.avatar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ProfileSidebar({result}) {
|
||||
return (
|
||||
<div>
|
||||
{result.model.photos}
|
||||
{result.model.more.friends}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ProfilePosts({result}) {
|
||||
return <div>{result.model.more.posts}</div>;
|
||||
}
|
||||
function ProfileGames({result}) {
|
||||
return <div>{result.model.more.games}</div>;
|
||||
}
|
||||
function ProfilePage({result}) {
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<p>(loading)</p>}>
|
||||
<ProfileDetails result={result} />
|
||||
<Suspense fallback={<p>(loading sidebar)</p>}>
|
||||
<ProfileSidebar result={result} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<p>(loading posts)</p>}>
|
||||
<ProfilePosts result={result} />
|
||||
</Suspense>
|
||||
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
|
||||
<Suspense fallback={<p>(loading games)</p>}>
|
||||
<ProfileGames result={result} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let {writable, readable} = getTestStream();
|
||||
ReactFlightDOMServer.pipeToNodeWritable(<ProfileModel />, writable);
|
||||
let result = ReactFlightDOMClient.readFromReadableStream(readable);
|
||||
|
||||
let container = document.createElement('div');
|
||||
let root = ReactDOM.createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ProfilePage result={result} />);
|
||||
});
|
||||
expect(container.innerHTML).toBe('<p>(loading)</p>');
|
||||
|
||||
// This isn't enough to show anything.
|
||||
await act(async () => {
|
||||
resolveFriendsModel();
|
||||
});
|
||||
expect(container.innerHTML).toBe('<p>(loading)</p>');
|
||||
|
||||
// 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(
|
||||
'<div>:name::avatar:</div>' +
|
||||
'<p>(loading sidebar)</p>' +
|
||||
'<p>(loading posts)</p>' +
|
||||
'<p>(loading games)</p>',
|
||||
);
|
||||
|
||||
// Let's *fail* loading games.
|
||||
await act(async () => {
|
||||
rejectGamesModel(new Error('Game over'));
|
||||
});
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div>:name::avatar:</div>' +
|
||||
'<p>(loading sidebar)</p>' +
|
||||
'<p>(loading posts)</p>' +
|
||||
'<p>Game over</p>', // TODO: should not have message in prod.
|
||||
);
|
||||
|
||||
// We can now show the sidebar.
|
||||
await act(async () => {
|
||||
resolvePhotosModel();
|
||||
});
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div>:name::avatar:</div>' +
|
||||
'<div>:photos::friends:</div>' +
|
||||
'<p>(loading posts)</p>' +
|
||||
'<p>Game over</p>', // TODO: should not have message in prod.
|
||||
);
|
||||
|
||||
// Show everything.
|
||||
await act(async () => {
|
||||
resolvePostsModel();
|
||||
});
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div>:name::avatar:</div>' +
|
||||
'<div>:photos::friends:</div>' +
|
||||
'<div>:posts:</div>' +
|
||||
'<p>Game over</p>', // TODO: should not have message in prod.
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user