main
js 268 lines 7.38 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 let React;
13 let ReactDOM;
14 let ReactDOMClient;
15 let ReactDOMServer;
16 let act;
17 let SuspenseList;
18
19 describe('ReactDOMServerSuspense', () => {
20 beforeEach(() => {
21 // Reset warning cache.
22 jest.resetModules();
23
24 React = require('react');
25 ReactDOM = require('react-dom');
26 ReactDOMClient = require('react-dom/client');
27 ReactDOMServer = require('react-dom/server');
28 act = require('internal-test-utils').act;
29 if (gate(flags => flags.enableSuspenseList)) {
30 SuspenseList = React.unstable_SuspenseList;
31 }
32 });
33
34 function Text(props) {
35 return <div>{props.text}</div>;
36 }
37
38 function AsyncText(props) {
39 throw new Promise(() => {});
40 }
41
42 function getVisibleChildren(element) {
43 const children = [];
44 let node = element.firstChild;
45 while (node) {
46 if (node.nodeType === 1) {
47 if (
48 node.tagName !== 'SCRIPT' &&
49 node.tagName !== 'TEMPLATE' &&
50 node.tagName !== 'template' &&
51 !node.hasAttribute('hidden') &&
52 !node.hasAttribute('aria-hidden')
53 ) {
54 const props = {};
55 const attributes = node.attributes;
56 for (let i = 0; i < attributes.length; i++) {
57 if (
58 attributes[i].name === 'id' &&
59 attributes[i].value.includes(':')
60 ) {
61 // We assume this is a React added ID that's a non-visual implementation detail.
62 continue;
63 }
64 props[attributes[i].name] = attributes[i].value;
65 }
66 props.children = getVisibleChildren(node);
67 children.push(React.createElement(node.tagName.toLowerCase(), props));
68 }
69 } else if (node.nodeType === 3) {
70 children.push(node.data);
71 }
72 node = node.nextSibling;
73 }
74 return children.length === 0
75 ? undefined
76 : children.length === 1
77 ? children[0]
78 : children;
79 }
80
81 it('should render the children when no promise is thrown', async () => {
82 const container = document.createElement('div');
83 const html = ReactDOMServer.renderToString(
84 <React.Suspense fallback={<Text text="Fallback" />}>
85 <Text text="Children" />
86 </React.Suspense>,
87 );
88 container.innerHTML = html;
89 expect(getVisibleChildren(container)).toEqual(<div>Children</div>);
90 });
91
92 it('should render the fallback when a promise thrown', async () => {
93 const container = document.createElement('div');
94 const html = ReactDOMServer.renderToString(
95 <React.Suspense fallback={<Text text="Fallback" />}>
96 <AsyncText text="Children" />
97 </React.Suspense>,
98 );
99 container.innerHTML = html;
100 expect(getVisibleChildren(container)).toEqual(<div>Fallback</div>);
101 });
102
103 // @gate enableBrowserAPI
104 it('hydrates browser-only content rendered with renderToString', async () => {
105 function BrowserOnly() {
106 React.use(ReactDOM.browser('Only render this content in the browser'));
107 return <Text text="Children" />;
108 }
109
110 const app = (
111 <React.Suspense fallback={<Text text="Fallback" />}>
112 <BrowserOnly />
113 </React.Suspense>
114 );
115 const container = document.createElement('div');
116 container.innerHTML = ReactDOMServer.renderToString(app);
117 expect(getVisibleChildren(container)).toEqual(<div>Fallback</div>);
118
119 const recoverableErrors = [];
120 await act(() => {
121 ReactDOMClient.hydrateRoot(container, app, {
122 onRecoverableError(error) {
123 recoverableErrors.push(error);
124 },
125 });
126 });
127
128 expect(recoverableErrors).toEqual([]);
129 expect(getVisibleChildren(container)).toEqual(<div>Children</div>);
130 });
131
132 // @gate enableBrowserAPI
133 it('renders only the browser-only fallback with renderToStaticMarkup', () => {
134 function BrowserOnly() {
135 React.use(ReactDOM.browser('Only render this content in the browser'));
136 return <Text text="Children" />;
137 }
138
139 const html = ReactDOMServer.renderToStaticMarkup(
140 <React.Suspense fallback={<Text text="Fallback" />}>
141 <BrowserOnly />
142 </React.Suspense>,
143 );
144
145 expect(html).toBe('<div>Fallback</div>');
146 });
147
148 it('should work with nested suspense components', async () => {
149 const container = document.createElement('div');
150 const html = ReactDOMServer.renderToString(
151 <React.Suspense fallback={<Text text="Fallback" />}>
152 <div>
153 <Text text="Children" />
154 <React.Suspense fallback={<Text text="Fallback" />}>
155 <AsyncText text="Children" />
156 </React.Suspense>
157 </div>
158 </React.Suspense>,
159 );
160 container.innerHTML = html;
161
162 expect(getVisibleChildren(container)).toEqual(
163 <div>
164 <div>Children</div>
165 <div>Fallback</div>
166 </div>,
167 );
168 });
169
170 // @gate enableSuspenseList
171 it('server renders a SuspenseList component and its children', async () => {
172 const example = (
173 <SuspenseList revealOrder="forwards" tail="visible">
174 <React.Suspense fallback="Loading A">
175 <div>A</div>
176 </React.Suspense>
177 <React.Suspense fallback="Loading B">
178 <div>B</div>
179 </React.Suspense>
180 </SuspenseList>
181 );
182 const container = document.createElement('div');
183 const html = ReactDOMServer.renderToString(example);
184 container.innerHTML = html;
185
186 const divA = container.children[0];
187 expect(divA.tagName).toBe('DIV');
188 expect(divA.textContent).toBe('A');
189 const divB = container.children[1];
190 expect(divB.tagName).toBe('DIV');
191 expect(divB.textContent).toBe('B');
192
193 await act(() => {
194 ReactDOMClient.hydrateRoot(container, example);
195 });
196
197 const divA2 = container.children[0];
198 const divB2 = container.children[1];
199 expect(divA).toBe(divA2);
200 expect(divB).toBe(divB2);
201 });
202
203 it('throws when rendering a suspending component outside a Suspense node', async () => {
204 expect(() => {
205 ReactDOMServer.renderToString(
206 <div>
207 <React.Suspense />
208 <AsyncText text="Children" />
209 <React.Suspense />
210 </div>,
211 );
212 }).toThrow('A component suspended while responding to synchronous input.');
213 });
214
215 it('does not get confused by throwing null', () => {
216 function Bad() {
217 // eslint-disable-next-line no-throw-literal
218 throw null;
219 }
220
221 let didError;
222 let error;
223 try {
224 ReactDOMServer.renderToString(<Bad />);
225 } catch (err) {
226 didError = true;
227 error = err;
228 }
229 expect(didError).toBe(true);
230 expect(error).toBe(null);
231 });
232
233 it('does not get confused by throwing undefined', () => {
234 function Bad() {
235 // eslint-disable-next-line no-throw-literal
236 throw undefined;
237 }
238
239 let didError;
240 let error;
241 try {
242 ReactDOMServer.renderToString(<Bad />);
243 } catch (err) {
244 didError = true;
245 error = err;
246 }
247 expect(didError).toBe(true);
248 expect(error).toBe(undefined);
249 });
250
251 it('does not get confused by throwing a primitive', () => {
252 function Bad() {
253 // eslint-disable-next-line no-throw-literal
254 throw 'foo';
255 }
256
257 let didError;
258 let error;
259 try {
260 ReactDOMServer.renderToString(<Bad />);
261 } catch (err) {
262 didError = true;
263 error = err;
264 }
265 expect(didError).toBe(true);
266 expect(error).toBe('foo');
267 });
268 });