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

Partial Hydration (#14717)

* Basic partial hydration test * Render comments around Suspense components We need this to be able to identify how far to skip ahead if we're not going to hydrate this subtree yet. * Add DehydratedSuspenseComponent type of work Will be used for Suspense boundaries that are left with their server rendered content intact. * Add comment node as hydratable instance type as placeholder for suspense * Skip past nodes within the Suspense boundary This lets us continue hydrating sibling nodes. * A dehydrated suspense boundary comment should be considered a sibling * Retry hydrating at offscreen pri or after ping if suspended * Enter hydration state when retrying dehydrated suspense boundary * Delete all children within a dehydrated suspense boundary when it's deleted * Delete server rendered content when props change before hydration completes * Make test internal * Wrap in act * Change SSR Fixture to use Partial Hydration This requires the enableSuspenseServerRenderer flag to be manually enabled for the build to work. * Changes to any parent Context forces clearing dehydrated content We mark dehydrated boundaries as having child work, since they might have components that read from the changed context. We check this in beginWork and if it does we treat it as if the input has changed (same as if props changes). * Wrap in feature flag * Treat Suspense boundaries without fallbacks as if not-boundaries These don't come into play for purposes of hydration. * Fix clearing of nested suspense boundaries * ping -> retry Co-Authored-By: sebmarkbage <sebastian@calyptus.eu> * Typo Co-Authored-By: sebmarkbage <sebastian@calyptus.eu> * Use didReceiveUpdate instead of manually comparing props * Leave comment for why it's ok to ignore the timeout

Sebastian Markbåge committed Feb 11, 2019 at 21:25 UTC f3a14951ab9bccfd59ca977493b72321b24e50a5
24 files changed +1417 -130
fixtures/ssr/src/components/App.js
+27 -12
@@ -1,17 +1,32 @@
1 -import React, {Component} from 'react';
1 +import React, {useContext, useState, Suspense} from 'react';
2
3 import Chrome from './Chrome';
4 import Page from './Page';
5 +import Page2 from './Page2';
6 +import Theme from './Theme';
7
6 -export default class App extends Component {
7 - render() {
8 - return (
9 - <Chrome title="Hello World" assets={this.props.assets}>
10 - <div>
11 - <h1>Hello World</h1>
12 - <Page />
13 - </div>
14 - </Chrome>
15 - );
16 - }
8 +function LoadingIndicator() {
9 + let theme = useContext(Theme);
10 + return <div className={theme + '-loading'}>Loading...</div>;
11 +}
12 +
13 +export default function App({assets}) {
14 + let [CurrentPage, switchPage] = useState(() => Page);
15 + return (
16 + <Chrome title="Hello World" assets={assets}>
17 + <div>
18 + <h1>Hello World</h1>
19 + <a className="link" onClick={() => switchPage(() => Page)}>
20 + Page 1
21 + </a>
22 + {' | '}
23 + <a className="link" onClick={() => switchPage(() => Page2)}>
24 + Page 2
25 + </a>
26 + <Suspense fallback={<LoadingIndicator />}>
27 + <CurrentPage />
28 + </Suspense>
29 + </div>
30 + </Chrome>
31 + );
32 }
fixtures/ssr/src/components/Chrome.css
+24
@@ -3,3 +3,27 @@ body {
3 padding: 0;
4 font-family: sans-serif;
5 }
6 +
7 +body.light {
8 + background-color: #FFFFFF;
9 + color: #333333;
10 +}
11 +
12 +body.dark {
13 + background-color: #000000;
14 + color: #CCCCCC;
15 +}
16 +
17 +.light-loading {
18 + margin: 10px 0;
19 + padding: 10px;
20 + background-color: #CCCCCC;
21 + color: #666666;
22 +}
23 +
24 +.dark-loading {
25 + margin: 10px 0;
26 + padding: 10px;
27 + background-color: #333333;
28 + color: #999999;
29 +}
fixtures/ssr/src/components/Chrome.js
+10 -2
@@ -1,8 +1,11 @@
1 import React, {Component} from 'react';
2
3 +import Theme, {ThemeToggleButton} from './Theme';
4 +
5 import './Chrome.css';
6
7 export default class Chrome extends Component {
8 + state = {theme: 'light'};
9 render() {
10 const assets = this.props.assets;
11 return (
@@ -14,13 +17,18 @@ export default class Chrome extends Component {
17 <link rel="stylesheet" href={assets['main.css']} />
18 <title>{this.props.title}</title>
19 </head>
17 - <body>
20 + <body className={this.state.theme}>
21 <noscript
22 dangerouslySetInnerHTML={{
23 __html: `<b>Enable JavaScript to run this app.</b>`,
24 }}
25 />
23 - {this.props.children}
26 + <Theme.Provider value={this.state.theme}>
27 + {this.props.children}
28 + <div>
29 + <ThemeToggleButton onChange={theme => this.setState({theme})} />
30 + </div>
31 + </Theme.Provider>
32 <script
33 dangerouslySetInnerHTML={{
34 __html: `assetManifest = ${JSON.stringify(assets)};`,
fixtures/ssr/src/components/Page.css
+14 -1
@@ -1,3 +1,16 @@
1 -.bold {
1 +.link {
2 font-weight: bold;
3 + cursor: pointer;
4 +}
5 +.light-box {
6 + margin: 10px 0;
7 + padding: 10px;
8 + background-color: #CCCCCC;
9 + color: #333333;
10 +}
11 +.dark-box {
12 + margin: 10px 0;
13 + padding: 10px;
14 + background-color: #333333;
15 + color: #CCCCCC;
16 }
fixtures/ssr/src/components/Page.js
+14 -6
@@ -1,5 +1,8 @@
1 import React, {Component} from 'react';
2
3 +import Theme from './Theme';
4 +import Suspend from './Suspend';
5 +
6 import './Page.css';
7
8 const autofocusedInputs = [
@@ -14,17 +17,22 @@ export default class Page extends Component {
17 };
18 render() {
19 const link = (
17 - <a className="bold" onClick={this.handleClick}>
20 + <a className="link" onClick={this.handleClick}>
21 Click Here
22 </a>
23 );
24 return (
22 - <div>
23 - <p suppressHydrationWarning={true}>A random number: {Math.random()}</p>
24 - <p>Autofocus on page load: {autofocusedInputs}</p>
25 - <p>{!this.state.active ? link : 'Thanks!'}</p>
26 - {this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>}
25 + <div className={this.context + '-box'}>
26 + <Suspend>
27 + <p suppressHydrationWarning={true}>
28 + A random number: {Math.random()}
29 + </p>
30 + <p>Autofocus on page load: {autofocusedInputs}</p>
31 + <p>{!this.state.active ? link : 'Thanks!'}</p>
32 + {this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>}
33 + </Suspend>
34 </div>
35 );
36 }
37 }
38 +Page.contextType = Theme;
fixtures/ssr/src/components/Page2.js new
+15
@@ -0,0 +1,15 @@
1 +import React, {useContext} from 'react';
2 +
3 +import Theme from './Theme';
4 +import Suspend from './Suspend';
5 +
6 +import './Page.css';
7 +
8 +export default function Page2() {
9 + let theme = useContext(Theme);
10 + return (
11 + <div className={theme + '-box'}>
12 + <Suspend>Content of a different page</Suspend>
13 + </div>
14 + );
15 +}
fixtures/ssr/src/components/Suspend.js new
+21
@@ -0,0 +1,21 @@
1 +let promise = null;
2 +let isResolved = false;
3 +
4 +export default function Suspend({children}) {
5 + // This will suspend the content from rendering but only on the client.
6 + // This is used to demo a slow loading app.
7 + if (typeof window === 'object') {
8 + if (!isResolved) {
9 + if (promise === null) {
10 + promise = new Promise(resolve => {
11 + setTimeout(() => {
12 + isResolved = true;
13 + resolve();
14 + }, 6000);
15 + });
16 + }
17 + throw promise;
18 + }
19 + }
20 + return children;
21 +}
fixtures/ssr/src/components/Theme.js new
+25
@@ -0,0 +1,25 @@
1 +import React, {createContext, useContext, useState} from 'react';
2 +
3 +const Theme = createContext('light');
4 +
5 +export default Theme;
6 +
7 +export function ThemeToggleButton({onChange}) {
8 + let theme = useContext(Theme);
9 + let [targetTheme, setTargetTheme] = useState(theme);
10 + function toggleTheme() {
11 + let newTheme = theme === 'light' ? 'dark' : 'light';
12 + // High pri, responsive update.
13 + setTargetTheme(newTheme);
14 + // Perform the actual theme change in a separate update.
15 + setTimeout(() => onChange(newTheme), 0);
16 + }
17 + if (targetTheme !== theme) {
18 + return 'Switching to ' + targetTheme + '...';
19 + }
20 + return (
21 + <a className="link" onClick={toggleTheme}>
22 + Switch to {theme === 'light' ? 'Dark' : 'Light'} theme
23 + </a>
24 + );
25 +}
fixtures/ssr/src/index.js
+3 -2
@@ -1,6 +1,7 @@
1 import React from 'react';
2 -import {hydrate} from 'react-dom';
2 +import {unstable_createRoot} from 'react-dom';
3
4 import App from './components/App';
5
6 -hydrate(<App assets={window.assetManifest} />, document);
6 +let root = unstable_createRoot(document, {hydrate: true});
7 +root.render(<App assets={window.assetManifest} />);
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js new
+637
@@ -0,0 +1,637 @@
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 +let React;
13 +let ReactDOM;
14 +let ReactDOMServer;
15 +let ReactFeatureFlags;
16 +let Suspense;
17 +let act;
18 +
19 +describe('ReactDOMServerPartialHydration', () => {
20 + beforeEach(() => {
21 + jest.resetModuleRegistry();
22 +
23 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
24 + ReactFeatureFlags.enableSuspenseServerRenderer = true;
25 +
26 + React = require('react');
27 + ReactDOM = require('react-dom');
28 + act = require('react-dom/test-utils').act;
29 + ReactDOMServer = require('react-dom/server');
30 + Suspense = React.Suspense;
31 + });
32 +
33 + it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
34 + let suspend = false;
35 + let resolve;
36 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
37 + let ref = React.createRef();
38 +
39 + function Child() {
40 + if (suspend) {
41 + throw promise;
42 + } else {
43 + return 'Hello';
44 + }
45 + }
46 +
47 + function App() {
48 + return (
49 + <div>
50 + <Suspense fallback="Loading...">
51 + <span ref={ref}>
52 + <Child />
53 + </span>
54 + </Suspense>
55 + </div>
56 + );
57 + }
58 +
59 + // First we render the final HTML. With the streaming renderer
60 + // this may have suspense points on the server but here we want
61 + // to test the completed HTML. Don't suspend on the server.
62 + suspend = false;
63 + let finalHTML = ReactDOMServer.renderToString(<App />);
64 +
65 + let container = document.createElement('div');
66 + container.innerHTML = finalHTML;
67 +
68 + let span = container.getElementsByTagName('span')[0];
69 +
70 + // On the client we don't have all data yet but we want to start
71 + // hydrating anyway.
72 + suspend = true;
73 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
74 + root.render(<App />);
75 + jest.runAllTimers();
76 +
77 + expect(ref.current).toBe(null);
78 +
79 + // Resolving the promise should continue hydration
80 + suspend = false;
81 + resolve();
82 + await promise;
83 + jest.runAllTimers();
84 +
85 + // We should now have hydrated with a ref on the existing span.
86 + expect(ref.current).toBe(span);
87 + });
88 +
89 + it('can insert siblings before the dehydrated boundary', () => {
90 + let suspend = false;
91 + let promise = new Promise(() => {});
92 + let showSibling;
93 +
94 + function Child() {
95 + if (suspend) {
96 + throw promise;
97 + } else {
98 + return 'Second';
99 + }
100 + }
101 +
102 + function Sibling() {
103 + let [visible, setVisibilty] = React.useState(false);
104 + showSibling = () => setVisibilty(true);
105 + if (visible) {
106 + return <div>First</div>;
107 + }
108 + return null;
109 + }
110 +
111 + function App() {
112 + return (
113 + <div>
114 + <Sibling />
115 + <Suspense fallback="Loading...">
116 + <span>
117 + <Child />
118 + </span>
119 + </Suspense>
120 + </div>
121 + );
122 + }
123 +
124 + suspend = false;
125 + let finalHTML = ReactDOMServer.renderToString(<App />);
126 + let container = document.createElement('div');
127 + container.innerHTML = finalHTML;
128 +
129 + // On the client we don't have all data yet but we want to start
130 + // hydrating anyway.
131 + suspend = true;
132 +
133 + act(() => {
134 + ReactDOM.hydrate(<App />, container);
135 + });
136 +
137 + expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
138 +
139 + // In this state, we can still update the siblings.
140 + act(() => showSibling());
141 +
142 + expect(container.firstChild.firstChild.tagName).toBe('DIV');
143 + expect(container.firstChild.firstChild.textContent).toBe('First');
144 + });
145 +
146 + it('can delete the dehydrated boundary before it is hydrated', () => {
147 + let suspend = false;
148 + let promise = new Promise(() => {});
149 + let hideMiddle;
150 +
151 + function Child() {
152 + if (suspend) {
153 + throw promise;
154 + } else {
155 + return (
156 + <React.Fragment>
157 + <div>Middle</div>
158 + Some text
159 + </React.Fragment>
160 + );
161 + }
162 + }
163 +
164 + function App() {
165 + let [visible, setVisibilty] = React.useState(true);
166 + hideMiddle = () => setVisibilty(false);
167 +
168 + return (
169 + <div>
170 + <div>Before</div>
171 + {visible ? (
172 + <Suspense fallback="Loading...">
173 + <Child />
174 + </Suspense>
175 + ) : null}
176 + <div>After</div>
177 + </div>
178 + );
179 + }
180 +
181 + suspend = false;
182 + let finalHTML = ReactDOMServer.renderToString(<App />);
183 + let container = document.createElement('div');
184 + container.innerHTML = finalHTML;
185 +
186 + // On the client we don't have all data yet but we want to start
187 + // hydrating anyway.
188 + suspend = true;
189 + act(() => {
190 + ReactDOM.hydrate(<App />, container);
191 + });
192 +
193 + expect(container.firstChild.children[1].textContent).toBe('Middle');
194 +
195 + // In this state, we can still delete the boundary.
196 + act(() => hideMiddle());
197 +
198 + expect(container.firstChild.children[1].textContent).toBe('After');
199 + });
200 +
201 + it('regenerates the content if props have changed before hydration completes', async () => {
202 + let suspend = false;
203 + let resolve;
204 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
205 + let ref = React.createRef();
206 +
207 + function Child({text}) {
208 + if (suspend) {
209 + throw promise;
210 + } else {
211 + return text;
212 + }
213 + }
214 +
215 + function App({text, className}) {
216 + return (
217 + <div>
218 + <Suspense fallback="Loading...">
219 + <span ref={ref} className={className}>
220 + <Child text={text} />
221 + </span>
222 + </Suspense>
223 + </div>
224 + );
225 + }
226 +
227 + suspend = false;
228 + let finalHTML = ReactDOMServer.renderToString(
229 + <App text="Hello" className="hello" />,
230 + );
231 + let container = document.createElement('div');
232 + container.innerHTML = finalHTML;
233 +
234 + let span = container.getElementsByTagName('span')[0];
235 +
236 + // On the client we don't have all data yet but we want to start
237 + // hydrating anyway.
238 + suspend = true;
239 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
240 + root.render(<App text="Hello" className="hello" />);
241 + jest.runAllTimers();
242 +
243 + expect(ref.current).toBe(null);
244 + expect(span.textContent).toBe('Hello');
245 +
246 + // Render an update, which will be higher or the same priority as pinging the hydration.
247 + root.render(<App text="Hi" className="hi" />);
248 +
249 + // At the same time, resolving the promise so that rendering can complete.
250 + suspend = false;
251 + resolve();
252 + await promise;
253 +
254 + // Flushing both of these in the same batch won't be able to hydrate so we'll
255 + // probably throw away the existing subtree.
256 + jest.runAllTimers();
257 +
258 + // Pick up the new span. In an ideal implementation this might be the same span
259 + // but patched up. At the time of writing, this will be a new span though.
260 + span = container.getElementsByTagName('span')[0];
261 +
262 + // We should now have fully rendered with a ref on the new span.
263 + expect(ref.current).toBe(span);
264 + expect(span.textContent).toBe('Hi');
265 + // If we ended up hydrating the existing content, we won't have properly
266 + // patched up the tree, which might mean we haven't patched the className.
267 + expect(span.className).toBe('hi');
268 + });
269 +
270 + it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
271 + let suspend = false;
272 + let resolve;
273 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
274 + let ref = React.createRef();
275 +
276 + function Child({text}) {
277 + if (suspend) {
278 + throw promise;
279 + } else {
280 + return text;
281 + }
282 + }
283 +
284 + function App({text, className}) {
285 + return (
286 + <div>
287 + <Suspense fallback="Loading...">
288 + <span ref={ref} className={className}>
289 + <Child text={text} />
290 + </span>
291 + </Suspense>
292 + </div>
293 + );
294 + }
295 +
296 + suspend = false;
297 + let finalHTML = ReactDOMServer.renderToString(
298 + <App text="Hello" className="hello" />,
299 + );
300 + let container = document.createElement('div');
301 + container.innerHTML = finalHTML;
302 +
303 + // On the client we don't have all data yet but we want to start
304 + // hydrating anyway.
305 + suspend = true;
306 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
307 + root.render(<App text="Hello" className="hello" />);
308 + jest.runAllTimers();
309 +
310 + expect(ref.current).toBe(null);
311 +
312 + // Render an update, but leave it still suspended.
313 + root.render(<App text="Hi" className="hi" />);
314 +
315 + // Flushing now should delete the existing content and show the fallback.
316 + jest.runAllTimers();
317 +
318 + expect(container.getElementsByTagName('span').length).toBe(0);
319 + expect(ref.current).toBe(null);
320 + expect(container.textContent).toBe('Loading...');
321 +
322 + // Unsuspending shows the content.
323 + suspend = false;
324 + resolve();
325 + await promise;
326 +
327 + jest.runAllTimers();
328 +
329 + let span = container.getElementsByTagName('span')[0];
330 + expect(span.textContent).toBe('Hi');
331 + expect(span.className).toBe('hi');
332 + expect(ref.current).toBe(span);
333 + expect(container.textContent).toBe('Hi');
334 + });
335 +
336 + it('shows the fallback of the outer if fallback is missing', async () => {
337 + // This is the same exact test as above but with a nested Suspense without a fallback.
338 + // This should be a noop.
339 + let suspend = false;
340 + let resolve;
341 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
342 + let ref = React.createRef();
343 +
344 + function Child({text}) {
345 + if (suspend) {
346 + throw promise;
347 + } else {
348 + return text;
349 + }
350 + }
351 +
352 + function App({text, className}) {
353 + return (
354 + <div>
355 + <Suspense fallback="Loading...">
356 + <span ref={ref} className={className}>
357 + <Suspense maxDuration={200}>
358 + <Child text={text} />
359 + </Suspense>
360 + </span>
361 + </Suspense>
362 + </div>
363 + );
364 + }
365 +
366 + suspend = false;
367 + let finalHTML = ReactDOMServer.renderToString(
368 + <App text="Hello" className="hello" />,
369 + );
370 + let container = document.createElement('div');
371 + container.innerHTML = finalHTML;
372 +
373 + // On the client we don't have all data yet but we want to start
374 + // hydrating anyway.
375 + suspend = true;
376 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
377 + root.render(<App text="Hello" className="hello" />);
378 + jest.runAllTimers();
379 +
380 + expect(ref.current).toBe(null);
381 +
382 + // Render an update, but leave it still suspended.
383 + root.render(<App text="Hi" className="hi" />);
384 +
385 + // Flushing now should delete the existing content and show the fallback.
386 + jest.runAllTimers();
387 +
388 + expect(container.getElementsByTagName('span').length).toBe(0);
389 + expect(ref.current).toBe(null);
390 + expect(container.textContent).toBe('Loading...');
391 +
392 + // Unsuspending shows the content.
393 + suspend = false;
394 + resolve();
395 + await promise;
396 +
397 + jest.runAllTimers();
398 +
399 + let span = container.getElementsByTagName('span')[0];
400 + expect(span.textContent).toBe('Hi');
401 + expect(span.className).toBe('hi');
402 + expect(ref.current).toBe(span);
403 + expect(container.textContent).toBe('Hi');
404 + });
405 +
406 + it('clears nested suspense boundaries if they did not hydrate yet', async () => {
407 + let suspend = false;
408 + let resolve;
409 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
410 + let ref = React.createRef();
411 +
412 + function Child({text}) {
413 + if (suspend) {
414 + throw promise;
415 + } else {
416 + return text;
417 + }
418 + }
419 +
420 + function App({text, className}) {
421 + return (
422 + <div>
423 + <Suspense fallback="Loading...">
424 + <Suspense fallback="Never happens">
425 + <Child text={text} />
426 + </Suspense>{' '}
427 + <span ref={ref} className={className}>
428 + <Child text={text} />
429 + </span>
430 + </Suspense>
431 + </div>
432 + );
433 + }
434 +
435 + suspend = false;
436 + let finalHTML = ReactDOMServer.renderToString(
437 + <App text="Hello" className="hello" />,
438 + );
439 + let container = document.createElement('div');
440 + container.innerHTML = finalHTML;
441 +
442 + // On the client we don't have all data yet but we want to start
443 + // hydrating anyway.
444 + suspend = true;
445 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
446 + root.render(<App text="Hello" className="hello" />);
447 + jest.runAllTimers();
448 +
449 + expect(ref.current).toBe(null);
450 +
451 + // Render an update, but leave it still suspended.
452 + root.render(<App text="Hi" className="hi" />);
453 +
454 + // Flushing now should delete the existing content and show the fallback.
455 + jest.runAllTimers();
456 +
457 + expect(container.getElementsByTagName('span').length).toBe(0);
458 + expect(ref.current).toBe(null);
459 + expect(container.textContent).toBe('Loading...');
460 +
461 + // Unsuspending shows the content.
462 + suspend = false;
463 + resolve();
464 + await promise;
465 +
466 + jest.runAllTimers();
467 +
468 + let span = container.getElementsByTagName('span')[0];
469 + expect(span.textContent).toBe('Hi');
470 + expect(span.className).toBe('hi');
471 + expect(ref.current).toBe(span);
472 + expect(container.textContent).toBe('Hi Hi');
473 + });
474 +
475 + it('regenerates the content if context has changed before hydration completes', async () => {
476 + let suspend = false;
477 + let resolve;
478 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
479 + let ref = React.createRef();
480 + let Context = React.createContext(null);
481 +
482 + function Child() {
483 + let {text, className} = React.useContext(Context);
484 + if (suspend) {
485 + throw promise;
486 + } else {
487 + return (
488 + <span ref={ref} className={className}>
489 + {text}
490 + </span>
491 + );
492 + }
493 + }
494 +
495 + const App = React.memo(function App() {
496 + return (
497 + <div>
498 + <Suspense fallback="Loading...">
499 + <Child />
500 + </Suspense>
501 + </div>
502 + );
503 + });
504 +
505 + suspend = false;
506 + let finalHTML = ReactDOMServer.renderToString(
507 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
508 + <App />
509 + </Context.Provider>,
510 + );
511 + let container = document.createElement('div');
512 + container.innerHTML = finalHTML;
513 +
514 + let span = container.getElementsByTagName('span')[0];
515 +
516 + // On the client we don't have all data yet but we want to start
517 + // hydrating anyway.
518 + suspend = true;
519 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
520 + root.render(
521 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
522 + <App />
523 + </Context.Provider>,
524 + );
525 + jest.runAllTimers();
526 +
527 + expect(ref.current).toBe(null);
528 + expect(span.textContent).toBe('Hello');
529 +
530 + // Render an update, which will be higher or the same priority as pinging the hydration.
531 + root.render(
532 + <Context.Provider value={{text: 'Hi', className: 'hi'}}>
533 + <App />
534 + </Context.Provider>,
535 + );
536 +
537 + // At the same time, resolving the promise so that rendering can complete.
538 + suspend = false;
539 + resolve();
540 + await promise;
541 +
542 + // Flushing both of these in the same batch won't be able to hydrate so we'll
543 + // probably throw away the existing subtree.
544 + jest.runAllTimers();
545 +
546 + // Pick up the new span. In an ideal implementation this might be the same span
547 + // but patched up. At the time of writing, this will be a new span though.
548 + span = container.getElementsByTagName('span')[0];
549 +
550 + // We should now have fully rendered with a ref on the new span.
551 + expect(ref.current).toBe(span);
552 + expect(span.textContent).toBe('Hi');
553 + // If we ended up hydrating the existing content, we won't have properly
554 + // patched up the tree, which might mean we haven't patched the className.
555 + expect(span.className).toBe('hi');
556 + });
557 +
558 + it('shows the fallback if context has changed before hydration completes and is still suspended', async () => {
559 + let suspend = false;
560 + let resolve;
561 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
562 + let ref = React.createRef();
563 + let Context = React.createContext(null);
564 +
565 + function Child() {
566 + let {text, className} = React.useContext(Context);
567 + if (suspend) {
568 + throw promise;
569 + } else {
570 + return (
571 + <span ref={ref} className={className}>
572 + {text}
573 + </span>
574 + );
575 + }
576 + }
577 +
578 + const App = React.memo(function App() {
579 + return (
580 + <div>
581 + <Suspense fallback="Loading...">
582 + <Child />
583 + </Suspense>
584 + </div>
585 + );
586 + });
587 +
588 + suspend = false;
589 + let finalHTML = ReactDOMServer.renderToString(
590 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
591 + <App />
592 + </Context.Provider>,
593 + );
594 + let container = document.createElement('div');
595 + container.innerHTML = finalHTML;
596 +
597 + // On the client we don't have all data yet but we want to start
598 + // hydrating anyway.
599 + suspend = true;
600 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
601 + root.render(
602 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
603 + <App />
604 + </Context.Provider>,
605 + );
606 + jest.runAllTimers();
607 +
608 + expect(ref.current).toBe(null);
609 +
610 + // Render an update, but leave it still suspended.
611 + root.render(
612 + <Context.Provider value={{text: 'Hi', className: 'hi'}}>
613 + <App />
614 + </Context.Provider>,
615 + );
616 +
617 + // Flushing now should delete the existing content and show the fallback.
618 + jest.runAllTimers();
619 +
620 + expect(container.getElementsByTagName('span').length).toBe(0);
621 + expect(ref.current).toBe(null);
622 + expect(container.textContent).toBe('Loading...');
623 +
624 + // Unsuspending shows the content.
625 + suspend = false;
626 + resolve();
627 + await promise;
628 +
629 + jest.runAllTimers();
630 +
631 + let span = container.getElementsByTagName('span')[0];
632 + expect(span.textContent).toBe('Hi');
633 + expect(span.className).toBe('hi');
634 + expect(ref.current).toBe(span);
635 + expect(container.textContent).toBe('Hi');
636 + });
637 +});
packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js
+26 -17
@@ -52,38 +52,47 @@ describe('ReactDOMServerSuspense', () => {
52 }
53
54 it('should render the children when no promise is thrown', async () => {
55 - const e = await serverRender(
56 - <React.Suspense fallback={<Text text="Fallback" />}>
57 - <Text text="Children" />
58 - </React.Suspense>,
55 + const c = await serverRender(
56 + <div>
57 + <React.Suspense fallback={<Text text="Fallback" />}>
58 + <Text text="Children" />
59 + </React.Suspense>
60 + </div>,
61 );
62 + const e = c.children[0];
63
64 expect(e.tagName).toBe('DIV');
65 expect(e.textContent).toBe('Children');
66 });
67
68 it('should render the fallback when a promise thrown', async () => {
66 - const e = await serverRender(
67 - <React.Suspense fallback={<Text text="Fallback" />}>
68 - <AsyncText text="Children" />
69 - </React.Suspense>,
69 + const c = await serverRender(
70 + <div>
71 + <React.Suspense fallback={<Text text="Fallback" />}>
72 + <AsyncText text="Children" />
73 + </React.Suspense>
74 + </div>,
75 );
76 + const e = c.children[0];
77
78 expect(e.tagName).toBe('DIV');
79 expect(e.textContent).toBe('Fallback');
80 });
81
82 it('should work with nested suspense components', async () => {
77 - const e = await serverRender(
78 - <React.Suspense fallback={<Text text="Fallback" />}>
79 - <div>
80 - <Text text="Children" />
81 - <React.Suspense fallback={<Text text="Fallback" />}>
82 - <AsyncText text="Children" />
83 - </React.Suspense>
84 - </div>
85 - </React.Suspense>,
83 + const c = await serverRender(
84 + <div>
85 + <React.Suspense fallback={<Text text="Fallback" />}>
86 + <div>
87 + <Text text="Children" />
88 + <React.Suspense fallback={<Text text="Fallback" />}>
89 + <AsyncText text="Children" />
90 + </React.Suspense>
91 + </div>
92 + </React.Suspense>
93 + </div>,
94 );
95 + const e = c.children[0];
96
97 expect(e.innerHTML).toBe('<div>Children</div><div>Fallback</div>');
98 });
packages/react-dom/src/client/ReactDOMHostConfig.js
+128 -14
@@ -56,7 +56,8 @@ export type Props = {
56 export type Container = Element | Document;
57 export type Instance = Element;
58 export type TextInstance = Text;
59 -export type HydratableInstance = Element | Text;
59 +export type SuspenseInstance = Comment;
60 +export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
61 export type PublicInstance = Element | Text;
62 type HostContextDev = {
63 namespace: string,
@@ -73,6 +74,7 @@ import {
74 unstable_scheduleCallback as scheduleDeferredCallback,
75 unstable_cancelCallback as cancelDeferredCallback,
76 } from 'scheduler';
77 +import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
78 export {
79 unstable_now as now,
80 unstable_scheduleCallback as scheduleDeferredCallback,
@@ -85,6 +87,9 @@ if (__DEV__) {
87 SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning';
88 }
89
90 +const SUSPENSE_START_DATA = '$';
91 +const SUSPENSE_END_DATA = '/$';
92 +
93 const STYLE = 'style';
94
95 let eventsEnabled: ?boolean = null;
@@ -397,7 +402,7 @@ export function appendChildToContainer(
402 export function insertBefore(
403 parentInstance: Instance,
404 child: Instance | TextInstance,
400 - beforeChild: Instance | TextInstance,
405 + beforeChild: Instance | TextInstance | SuspenseInstance,
406 ): void {
407 parentInstance.insertBefore(child, beforeChild);
408 }
@@ -405,7 +410,7 @@ export function insertBefore(
410 export function insertInContainerBefore(
411 container: Container,
412 child: Instance | TextInstance,
408 - beforeChild: Instance | TextInstance,
413 + beforeChild: Instance | TextInstance | SuspenseInstance,
414 ): void {
415 if (container.nodeType === COMMENT_NODE) {
416 (container.parentNode: any).insertBefore(child, beforeChild);
@@ -416,14 +421,14 @@ export function insertInContainerBefore(
421
422 export function removeChild(
423 parentInstance: Instance,
419 - child: Instance | TextInstance,
424 + child: Instance | TextInstance | SuspenseInstance,
425 ): void {
426 parentInstance.removeChild(child);
427 }
428
429 export function removeChildFromContainer(
430 container: Container,
426 - child: Instance | TextInstance,
431 + child: Instance | TextInstance | SuspenseInstance,
432 ): void {
433 if (container.nodeType === COMMENT_NODE) {
434 (container.parentNode: any).removeChild(child);
@@ -432,6 +437,49 @@ export function removeChildFromContainer(
437 }
438 }
439
440 +export function clearSuspenseBoundary(
441 + parentInstance: Instance,
442 + suspenseInstance: SuspenseInstance,
443 +): void {
444 + let node = suspenseInstance;
445 + // Delete all nodes within this suspense boundary.
446 + // There might be nested nodes so we need to keep track of how
447 + // deep we are and only break out when we're back on top.
448 + let depth = 0;
449 + do {
450 + let nextNode = node.nextSibling;
451 + parentInstance.removeChild(node);
452 + if (nextNode && nextNode.nodeType === COMMENT_NODE) {
453 + let data = ((nextNode: any).data: string);
454 + if (data === SUSPENSE_END_DATA) {
455 + if (depth === 0) {
456 + parentInstance.removeChild(nextNode);
457 + return;
458 + } else {
459 + depth--;
460 + }
461 + } else if (data === SUSPENSE_START_DATA) {
462 + depth++;
463 + }
464 + }
465 + node = nextNode;
466 + } while (node);
467 + // TODO: Warn, we didn't find the end comment boundary.
468 +}
469 +
470 +export function clearSuspenseBoundaryFromContainer(
471 + container: Container,
472 + suspenseInstance: SuspenseInstance,
473 +): void {
474 + if (container.nodeType === COMMENT_NODE) {
475 + clearSuspenseBoundary((container.parentNode: any), suspenseInstance);
476 + } else if (container.nodeType === ELEMENT_NODE) {
477 + clearSuspenseBoundary((container: any), suspenseInstance);
478 + } else {
479 + // Document nodes should never contain suspense boundaries.
480 + }
481 +}
482 +
483 export function hideInstance(instance: Instance): void {
484 // TODO: Does this work for all element types? What about MathML? Should we
485 // pass host context to this method?
@@ -469,7 +517,7 @@ export function unhideTextInstance(
517 export const supportsHydration = true;
518
519 export function canHydrateInstance(
472 - instance: Instance | TextInstance,
520 + instance: HydratableInstance,
521 type: string,
522 props: Props,
523 ): null | Instance {
@@ -484,7 +532,7 @@ export function canHydrateInstance(
532 }
533
534 export function canHydrateTextInstance(
487 - instance: Instance | TextInstance,
535 + instance: HydratableInstance,
536 text: string,
537 ): null | TextInstance {
538 if (text === '' || instance.nodeType !== TEXT_NODE) {
@@ -495,15 +543,29 @@ export function canHydrateTextInstance(
543 return ((instance: any): TextInstance);
544 }
545
546 +export function canHydrateSuspenseInstance(
547 + instance: HydratableInstance,
548 +): null | SuspenseInstance {
549 + if (instance.nodeType !== COMMENT_NODE) {
550 + // Empty strings are not parsed by HTML so there won't be a correct match here.
551 + return null;
552 + }
553 + // This has now been refined to a suspense node.
554 + return ((instance: any): SuspenseInstance);
555 +}
556 +
557 export function getNextHydratableSibling(
499 - instance: Instance | TextInstance,
500 -): null | Instance | TextInstance {
558 + instance: HydratableInstance,
559 +): null | HydratableInstance {
560 let node = instance.nextSibling;
561 // Skip non-hydratable nodes.
562 while (
563 node &&
564 node.nodeType !== ELEMENT_NODE &&
506 - node.nodeType !== TEXT_NODE
565 + node.nodeType !== TEXT_NODE &&
566 + (!enableSuspenseServerRenderer ||
567 + node.nodeType !== COMMENT_NODE ||
568 + (node: any).data !== SUSPENSE_START_DATA)
569 ) {
570 node = node.nextSibling;
571 }
@@ -512,13 +574,16 @@ export function getNextHydratableSibling(
574
575 export function getFirstHydratableChild(
576 parentInstance: Container | Instance,
515 -): null | Instance | TextInstance {
577 +): null | HydratableInstance {
578 let next = parentInstance.firstChild;
579 // Skip non-hydratable nodes.
580 while (
581 next &&
582 next.nodeType !== ELEMENT_NODE &&
521 - next.nodeType !== TEXT_NODE
583 + next.nodeType !== TEXT_NODE &&
584 + (!enableSuspenseServerRenderer ||
585 + next.nodeType !== COMMENT_NODE ||
586 + (next: any).data !== SUSPENSE_START_DATA)
587 ) {
588 next = next.nextSibling;
589 }
@@ -562,6 +627,33 @@ export function hydrateTextInstance(
627 return diffHydratedText(textInstance, text);
628 }
629
630 +export function getNextHydratableInstanceAfterSuspenseInstance(
631 + suspenseInstance: SuspenseInstance,
632 +): null | HydratableInstance {
633 + let node = suspenseInstance.nextSibling;
634 + // Skip past all nodes within this suspense boundary.
635 + // There might be nested nodes so we need to keep track of how
636 + // deep we are and only break out when we're back on top.
637 + let depth = 0;
638 + while (node) {
639 + if (node.nodeType === COMMENT_NODE) {
640 + let data = ((node: any).data: string);
641 + if (data === SUSPENSE_END_DATA) {
642 + if (depth === 0) {
643 + return getNextHydratableSibling((node: any));
644 + } else {
645 + depth--;
646 + }
647 + } else if (data === SUSPENSE_START_DATA) {
648 + depth++;
649 + }
650 + }
651 + node = node.nextSibling;
652 + }
653 + // TODO: Warn, we didn't find the end comment boundary.
654 + return null;
655 +}
656 +
657 export function didNotMatchHydratedContainerTextInstance(
658 parentContainer: Container,
659 textInstance: TextInstance,
@@ -586,11 +678,13 @@ export function didNotMatchHydratedTextInstance(
678
679 export function didNotHydrateContainerInstance(
680 parentContainer: Container,
589 - instance: Instance | TextInstance,
681 + instance: HydratableInstance,
682 ) {
683 if (__DEV__) {
684 if (instance.nodeType === ELEMENT_NODE) {
685 warnForDeletedHydratableElement(parentContainer, (instance: any));
686 + } else if (instance.nodeType === COMMENT_NODE) {
687 + // TODO: warnForDeletedHydratableSuspenseBoundary
688 } else {
689 warnForDeletedHydratableText(parentContainer, (instance: any));
690 }
@@ -601,11 +695,13 @@ export function didNotHydrateInstance(
695 parentType: string,
696 parentProps: Props,
697 parentInstance: Instance,
604 - instance: Instance | TextInstance,
698 + instance: HydratableInstance,
699 ) {
700 if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
701 if (instance.nodeType === ELEMENT_NODE) {
702 warnForDeletedHydratableElement(parentInstance, (instance: any));
703 + } else if (instance.nodeType === COMMENT_NODE) {
704 + // TODO: warnForDeletedHydratableSuspenseBoundary
705 } else {
706 warnForDeletedHydratableText(parentInstance, (instance: any));
707 }
@@ -631,6 +727,14 @@ export function didNotFindHydratableContainerTextInstance(
727 }
728 }
729
730 +export function didNotFindHydratableContainerSuspenseInstance(
731 + parentContainer: Container,
732 +) {
733 + if (__DEV__) {
734 + // TODO: warnForInsertedHydratedSupsense(parentContainer);
735 + }
736 +}
737 +
738 export function didNotFindHydratableInstance(
739 parentType: string,
740 parentProps: Props,
@@ -653,3 +757,13 @@ export function didNotFindHydratableTextInstance(
757 warnForInsertedHydratedText(parentInstance, text);
758 }
759 }
760 +
761 +export function didNotFindHydratableSuspenseInstance(
762 + parentType: string,
763 + parentProps: Props,
764 + parentInstance: Instance,
765 +) {
766 + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
767 + // TODO: warnForInsertedHydratedSuspense(parentInstance);
768 + }
769 +}
packages/react-dom/src/server/ReactPartialRenderer.js
+23 -5
@@ -956,9 +956,27 @@ class ReactDOMServerRenderer {
956 }
957 case REACT_SUSPENSE_TYPE: {
958 if (enableSuspenseServerRenderer) {
959 - const fallbackChildren = toArray(
960 - ((nextChild: any): ReactElement).props.fallback,
961 - );
959 + const fallback = ((nextChild: any): ReactElement).props.fallback;
960 + if (fallback === undefined) {
961 + // If there is no fallback, then this just behaves as a fragment.
962 + const nextChildren = toArray(
963 + ((nextChild: any): ReactElement).props.children,
964 + );
965 + const frame: Frame = {
966 + type: null,
967 + domNamespace: parentNamespace,
968 + children: nextChildren,
969 + childIndex: 0,
970 + context: context,
971 + footer: '',
972 + };
973 + if (__DEV__) {
974 + ((frame: any): FrameDev).debugElementStack = [];
975 + }
976 + this.stack.push(frame);
977 + return '';
978 + }
979 + const fallbackChildren = toArray(fallback);
980 const nextChildren = toArray(
981 ((nextChild: any): ReactElement).props.children,
982 );
@@ -978,7 +996,7 @@ class ReactDOMServerRenderer {
996 children: nextChildren,
997 childIndex: 0,
998 context: context,
981 - footer: '',
999 + footer: '<!--/$-->',
1000 };
1001 if (__DEV__) {
1002 ((frame: any): FrameDev).debugElementStack = [];
@@ -986,7 +1004,7 @@ class ReactDOMServerRenderer {
1004 }
1005 this.stack.push(frame);
1006 this.suspenseDepth++;
989 - return '';
1007 + return '<!--$-->';
1008 } else {
1009 invariant(false, 'ReactDOMServer does not yet support Suspense.');
1010 }
packages/react-reconciler/src/ReactDebugFiberPerf.js
+3 -1
@@ -21,6 +21,7 @@ import {
21 ContextConsumer,
22 Mode,
23 SuspenseComponent,
24 + DehydratedSuspenseComponent,
25 } from 'shared/ReactWorkTags';
26
27 type MeasurementPhase =
@@ -317,7 +318,8 @@ export function stopFailedWorkTimer(fiber: Fiber): void {
318 }
319 fiber._debugIsCurrentlyTiming = false;
320 const warning =
320 - fiber.tag === SuspenseComponent
321 + fiber.tag === SuspenseComponent ||
322 + fiber.tag === DehydratedSuspenseComponent
323 ? 'Rendering was suspended'
324 : 'An error was thrown inside this error boundary';
325 endFiberMark(fiber, null, warning);
packages/react-reconciler/src/ReactFiberBeginWork.js
+116 -6
@@ -30,6 +30,7 @@ import {
30 ContextConsumer,
31 Profiler,
32 SuspenseComponent,
33 + DehydratedSuspenseComponent,
34 MemoComponent,
35 SimpleMemoComponent,
36 LazyComponent,
@@ -43,12 +44,14 @@ import {
44 DidCapture,
45 Update,
46 Ref,
47 + Deletion,
48 } from 'shared/ReactSideEffectTags';
49 import ReactSharedInternals from 'shared/ReactSharedInternals';
50 import {
51 debugRenderPhaseSideEffects,
52 debugRenderPhaseSideEffectsForStrictMode,
53 enableProfilerTimer,
54 + enableSuspenseServerRenderer,
55 } from 'shared/ReactFeatureFlags';
56 import invariant from 'shared/invariant';
57 import shallowEqual from 'shared/shallowEqual';
@@ -103,6 +106,7 @@ import {
106 } from './ReactFiberContext';
107 import {
108 enterHydrationState,
109 + reenterHydrationStateFromDehydratedSuspenseInstance,
110 resetHydrationState,
111 tryToClaimNextHydratableInstance,
112 } from './ReactFiberHydrationContext';
@@ -1392,6 +1396,22 @@ function updateSuspenseComponent(
1396 // children -- we skip over the primary children entirely.
1397 let next;
1398 if (current === null) {
1399 + if (enableSuspenseServerRenderer) {
1400 + // If we're currently hydrating, try to hydrate this boundary.
1401 + // But only if this has a fallback.
1402 + if (nextProps.fallback !== undefined) {
1403 + tryToClaimNextHydratableInstance(workInProgress);
1404 + // This could've changed the tag if this was a dehydrated suspense component.
1405 + if (workInProgress.tag === DehydratedSuspenseComponent) {
1406 + return updateDehydratedSuspenseComponent(
1407 + null,
1408 + workInProgress,
1409 + renderExpirationTime,
1410 + );
1411 + }
1412 + }
1413 + }
1414 +
1415 // This is the initial mount. This branch is pretty simple because there's
1416 // no previous state that needs to be preserved.
1417 if (nextDidTimeout) {
@@ -1598,6 +1618,78 @@ function updateSuspenseComponent(
1618 return next;
1619 }
1620
1621 +function updateDehydratedSuspenseComponent(
1622 + current: Fiber | null,
1623 + workInProgress: Fiber,
1624 + renderExpirationTime: ExpirationTime,
1625 +) {
1626 + if (current === null) {
1627 + // During the first pass, we'll bail out and not drill into the children.
1628 + // Instead, we'll leave the content in place and try to hydrate it later.
1629 + workInProgress.expirationTime = Never;
1630 + return null;
1631 + }
1632 + // We use childExpirationTime to indicate that a child might depend on context, so if
1633 + // any context has changed, we need to treat is as if the input might have changed.
1634 + const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
1635 + if (didReceiveUpdate || hasContextChanged) {
1636 + // This boundary has changed since the first render. This means that we are now unable to
1637 + // hydrate it. We might still be able to hydrate it using an earlier expiration time but
1638 + // during this render we can't. Instead, we're going to delete the whole subtree and
1639 + // instead inject a new real Suspense boundary to take its place, which may render content
1640 + // or fallback. The real Suspense boundary will suspend for a while so we have some time
1641 + // to ensure it can produce real content, but all state and pending events will be lost.
1642 +
1643 + // Detach from the current dehydrated boundary.
1644 + current.alternate = null;
1645 + workInProgress.alternate = null;
1646 +
1647 + // Insert a deletion in the effect list.
1648 + let returnFiber = workInProgress.return;
1649 + invariant(
1650 + returnFiber !== null,
1651 + 'Suspense boundaries are never on the root. ' +
1652 + 'This is probably a bug in React.',
1653 + );
1654 + const last = returnFiber.lastEffect;
1655 + if (last !== null) {
1656 + last.nextEffect = current;
1657 + returnFiber.lastEffect = current;
1658 + } else {
1659 + returnFiber.firstEffect = returnFiber.lastEffect = current;
1660 + }
1661 + current.nextEffect = null;
1662 + current.effectTag = Deletion;
1663 +
1664 + // Upgrade this work in progress to a real Suspense component.
1665 + workInProgress.tag = SuspenseComponent;
1666 + workInProgress.stateNode = null;
1667 + workInProgress.memoizedState = null;
1668 + // This is now an insertion.
1669 + workInProgress.effectTag |= Placement;
1670 + // Retry as a real Suspense component.
1671 + return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
1672 + }
1673 + if ((workInProgress.effectTag & DidCapture) === NoEffect) {
1674 + // This is the first attempt.
1675 + reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress);
1676 + const nextProps = workInProgress.pendingProps;
1677 + const nextChildren = nextProps.children;
1678 + workInProgress.child = mountChildFibers(
1679 + workInProgress,
1680 + null,
1681 + nextChildren,
1682 + renderExpirationTime,
1683 + );
1684 + return workInProgress.child;
1685 + } else {
1686 + // Something suspended. Leave the existing children in place.
1687 + // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far?
1688 + workInProgress.child = null;
1689 + return null;
1690 + }
1691 +}
1692 +
1693 function updatePortalComponent(
1694 current: Fiber | null,
1695 workInProgress: Fiber,
@@ -1882,6 +1974,15 @@ function beginWork(
1974 }
1975 break;
1976 }
1977 + case DehydratedSuspenseComponent: {
1978 + if (enableSuspenseServerRenderer) {
1979 + // We know that this component will suspend again because if it has
1980 + // been unsuspended it has committed as a regular Suspense component.
1981 + // If it needs to be retried, it should have work scheduled on it.
1982 + workInProgress.effectTag |= DidCapture;
1983 + break;
1984 + }
1985 + }
1986 }
1987 return bailoutOnAlreadyFinishedWork(
1988 current,
@@ -2051,13 +2152,22 @@ function beginWork(
2152 renderExpirationTime,
2153 );
2154 }
2054 - default:
2055 - invariant(
2056 - false,
2057 - 'Unknown unit of work tag. This error is likely caused by a bug in ' +
2058 - 'React. Please file an issue.',
2059 - );
2155 + case DehydratedSuspenseComponent: {
2156 + if (enableSuspenseServerRenderer) {
2157 + return updateDehydratedSuspenseComponent(
2158 + current,
2159 + workInProgress,
2160 + renderExpirationTime,
2161 + );
2162 + }
2163 + break;
2164 + }
2165 }
2166 + invariant(
2167 + false,
2168 + 'Unknown unit of work tag. This error is likely caused by a bug in ' +
2169 + 'React. Please file an issue.',
2170 + );
2171 }
2172
2173 export {beginWork};
packages/react-reconciler/src/ReactFiberCommitWork.js
+34 -3
@@ -10,6 +10,7 @@
10 import type {
11 Instance,
12 TextInstance,
13 + SuspenseInstance,
14 Container,
15 ChildSet,
16 UpdatePayload,
@@ -26,6 +27,7 @@ import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
27 import {
28 enableSchedulerTracing,
29 enableProfilerTimer,
30 + enableSuspenseServerRenderer,
31 } from 'shared/ReactFeatureFlags';
32 import {
33 FunctionComponent,
@@ -37,6 +39,7 @@ import {
39 HostPortal,
40 Profiler,
41 SuspenseComponent,
42 + DehydratedSuspenseComponent,
43 IncompleteClassComponent,
44 MemoComponent,
45 SimpleMemoComponent,
@@ -79,6 +82,8 @@ import {
82 insertInContainerBefore,
83 removeChild,
84 removeChildFromContainer,
85 + clearSuspenseBoundary,
86 + clearSuspenseBoundaryFromContainer,
87 replaceContainerChildren,
88 createContainerChildSet,
89 hideInstance,
@@ -881,7 +886,11 @@ function getHostSibling(fiber: Fiber): ?Instance {
886 }
887 node.sibling.return = node.return;
888 node = node.sibling;
884 - while (node.tag !== HostComponent && node.tag !== HostText) {
889 + while (
890 + node.tag !== HostComponent &&
891 + node.tag !== HostText &&
892 + node.tag !== DehydratedSuspenseComponent
893 + ) {
894 // If it is not host node and, we might have a host node inside it.
895 // Try to search down until we find one.
896 if (node.effectTag & Placement) {
@@ -1032,11 +1041,33 @@ function unmountHostComponents(current): void {
1041 // After all the children have unmounted, it is now safe to remove the
1042 // node from the tree.
1043 if (currentParentIsContainer) {
1035 - removeChildFromContainer((currentParent: any), node.stateNode);
1044 + removeChildFromContainer(
1045 + ((currentParent: any): Container),
1046 + (node.stateNode: Instance | TextInstance),
1047 + );
1048 } else {
1037 - removeChild((currentParent: any), node.stateNode);
1049 + removeChild(
1050 + ((currentParent: any): Instance),
1051 + (node.stateNode: Instance | TextInstance),
1052 + );
1053 }
1054 // Don't visit children because we already visited them.
1055 + } else if (
1056 + enableSuspenseServerRenderer &&
1057 + node.tag === DehydratedSuspenseComponent
1058 + ) {
1059 + // Delete the dehydrated suspense boundary and all of its content.
1060 + if (currentParentIsContainer) {
1061 + clearSuspenseBoundaryFromContainer(
1062 + ((currentParent: any): Container),
1063 + (node.stateNode: SuspenseInstance),
1064 + );
1065 + } else {
1066 + clearSuspenseBoundary(
1067 + ((currentParent: any): Instance),
1068 + (node.stateNode: SuspenseInstance),
1069 + );
1070 + }
1071 } else if (node.tag === HostPortal) {
1072 if (node.child !== null) {
1073 // When we go into a portal, it becomes the parent to remove from.
packages/react-reconciler/src/ReactFiberCompleteWork.js
+26
@@ -34,6 +34,7 @@ import {
34 Mode,
35 Profiler,
36 SuspenseComponent,
37 + DehydratedSuspenseComponent,
38 MemoComponent,
39 SimpleMemoComponent,
40 LazyComponent,
@@ -80,8 +81,10 @@ import {popProvider} from './ReactFiberNewContext';
81 import {
82 prepareToHydrateHostInstance,
83 prepareToHydrateHostTextInstance,
84 + skipPastDehydratedSuspenseInstance,
85 popHydrationState,
86 } from './ReactFiberHydrationContext';
87 +import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
88
89 function markUpdate(workInProgress: Fiber) {
90 // Tag the fiber with an update effect. This turns a Placement into
@@ -762,6 +765,29 @@ function completeWork(
765 }
766 break;
767 }
768 + case DehydratedSuspenseComponent: {
769 + if (enableSuspenseServerRenderer) {
770 + if (current === null) {
771 + let wasHydrated = popHydrationState(workInProgress);
772 + invariant(
773 + wasHydrated,
774 + 'A dehydrated suspense component was completed without a hydrated node. ' +
775 + 'This is probably a bug in React.',
776 + );
777 + skipPastDehydratedSuspenseInstance(workInProgress);
778 + } else if ((workInProgress.effectTag & DidCapture) === NoEffect) {
779 + // This boundary did not suspend so it's now hydrated.
780 + // To handle any future suspense cases, we're going to now upgrade it
781 + // to a Suspense component. We detach it from the existing current fiber.
782 + current.alternate = null;
783 + workInProgress.alternate = null;
784 + workInProgress.tag = SuspenseComponent;
785 + workInProgress.memoizedState = null;
786 + workInProgress.stateNode = null;
787 + }
788 + }
789 + break;
790 + }
791 default:
792 invariant(
793 false,
packages/react-reconciler/src/ReactFiberHydrationContext.js
+72 -2
@@ -12,11 +12,18 @@ import type {
12 Instance,
13 TextInstance,
14 HydratableInstance,
15 + SuspenseInstance,
16 Container,
17 HostContext,
18 } from './ReactFiberHostConfig';
19
19 -import {HostComponent, HostText, HostRoot} from 'shared/ReactWorkTags';
20 +import {
21 + HostComponent,
22 + HostText,
23 + HostRoot,
24 + SuspenseComponent,
25 + DehydratedSuspenseComponent,
26 +} from 'shared/ReactWorkTags';
27 import {Deletion, Placement} from 'shared/ReactSideEffectTags';
28 import invariant from 'shared/invariant';
29
@@ -26,19 +33,24 @@ import {
33 supportsHydration,
34 canHydrateInstance,
35 canHydrateTextInstance,
36 + canHydrateSuspenseInstance,
37 getNextHydratableSibling,
38 getFirstHydratableChild,
39 hydrateInstance,
40 hydrateTextInstance,
41 + getNextHydratableInstanceAfterSuspenseInstance,
42 didNotMatchHydratedContainerTextInstance,
43 didNotMatchHydratedTextInstance,
44 didNotHydrateContainerInstance,
45 didNotHydrateInstance,
46 didNotFindHydratableContainerInstance,
47 didNotFindHydratableContainerTextInstance,
48 + didNotFindHydratableContainerSuspenseInstance,
49 didNotFindHydratableInstance,
50 didNotFindHydratableTextInstance,
51 + didNotFindHydratableSuspenseInstance,
52 } from './ReactFiberHostConfig';
53 +import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
54
55 // The deepest Fiber on the stack involved in a hydration context.
56 // This may have been an insertion or a hydration.
@@ -58,6 +70,20 @@ function enterHydrationState(fiber: Fiber): boolean {
70 return true;
71 }
72
73 +function reenterHydrationStateFromDehydratedSuspenseInstance(
74 + fiber: Fiber,
75 +): boolean {
76 + if (!supportsHydration) {
77 + return false;
78 + }
79 +
80 + const suspenseInstance = fiber.stateNode;
81 + nextHydratableInstance = getNextHydratableSibling(suspenseInstance);
82 + popToNextHostParent(fiber);
83 + isHydrating = true;
84 + return true;
85 +}
86 +
87 function deleteHydratableInstance(
88 returnFiber: Fiber,
89 instance: HydratableInstance,
@@ -115,6 +141,9 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
141 const text = fiber.pendingProps;
142 didNotFindHydratableContainerTextInstance(parentContainer, text);
143 break;
144 + case SuspenseComponent:
145 + didNotFindHydratableContainerSuspenseInstance(parentContainer);
146 + break;
147 }
148 break;
149 }
@@ -143,6 +172,13 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
172 text,
173 );
174 break;
175 + case SuspenseComponent:
176 + didNotFindHydratableSuspenseInstance(
177 + parentType,
178 + parentProps,
179 + parentInstance,
180 + );
181 + break;
182 }
183 break;
184 }
@@ -173,6 +209,18 @@ function tryHydrate(fiber, nextInstance) {
209 }
210 return false;
211 }
212 + case SuspenseComponent: {
213 + if (enableSuspenseServerRenderer) {
214 + const suspenseInstance = canHydrateSuspenseInstance(nextInstance);
215 + if (suspenseInstance !== null) {
216 + // Downgrade the tag to a dehydrated component until we've hydrated it.
217 + fiber.tag = DehydratedSuspenseComponent;
218 + fiber.stateNode = (suspenseInstance: SuspenseInstance);
219 + return true;
220 + }
221 + }
222 + return false;
223 + }
224 default:
225 return false;
226 }
@@ -296,12 +344,32 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
344 return shouldUpdate;
345 }
346
347 +function skipPastDehydratedSuspenseInstance(fiber: Fiber): void {
348 + if (!supportsHydration) {
349 + invariant(
350 + false,
351 + 'Expected skipPastDehydratedSuspenseInstance() to never be called. ' +
352 + 'This error is likely caused by a bug in React. Please file an issue.',
353 + );
354 + }
355 + let suspenseInstance = fiber.stateNode;
356 + invariant(
357 + suspenseInstance,
358 + 'Expected to have a hydrated suspense instance. ' +
359 + 'This error is likely caused by a bug in React. Please file an issue.',
360 + );
361 + nextHydratableInstance = getNextHydratableInstanceAfterSuspenseInstance(
362 + suspenseInstance,
363 + );
364 +}
365 +
366 function popToNextHostParent(fiber: Fiber): void {
367 let parent = fiber.return;
368 while (
369 parent !== null &&
370 parent.tag !== HostComponent &&
304 - parent.tag !== HostRoot
371 + parent.tag !== HostRoot &&
372 + parent.tag !== DehydratedSuspenseComponent
373 ) {
374 parent = parent.return;
375 }
@@ -365,9 +433,11 @@ function resetHydrationState(): void {
433
434 export {
435 enterHydrationState,
436 + reenterHydrationStateFromDehydratedSuspenseInstance,
437 resetHydrationState,
438 tryToClaimNextHydratableInstance,
439 prepareToHydrateHostInstance,
440 prepareToHydrateHostTextInstance,
441 + skipPastDehydratedSuspenseInstance,
442 popHydrationState,
443 };
packages/react-reconciler/src/ReactFiberNewContext.js
+62 -26
@@ -27,7 +27,11 @@ import warningWithoutStack from 'shared/warningWithoutStack';
27 import {isPrimaryRenderer} from './ReactFiberHostConfig';
28 import {createCursor, push, pop} from './ReactFiberStack';
29 import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt';
30 -import {ContextProvider, ClassComponent} from 'shared/ReactWorkTags';
30 +import {
31 + ContextProvider,
32 + ClassComponent,
33 + DehydratedSuspenseComponent,
34 +} from 'shared/ReactWorkTags';
35
36 import invariant from 'shared/invariant';
37 import warning from 'shared/warning';
@@ -39,6 +43,7 @@ import {
43 } from 'react-reconciler/src/ReactUpdateQueue';
44 import {NoWork} from './ReactFiberExpirationTime';
45 import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
46 +import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
47
48 const valueCursor: StackCursor<mixed> = createCursor(null);
49
@@ -150,6 +155,37 @@ export function calculateChangedBits<T>(
155 }
156 }
157
158 +function scheduleWorkOnParentPath(
159 + parent: Fiber | null,
160 + renderExpirationTime: ExpirationTime,
161 +) {
162 + // Update the child expiration time of all the ancestors, including
163 + // the alternates.
164 + let node = parent;
165 + while (node !== null) {
166 + let alternate = node.alternate;
167 + if (node.childExpirationTime < renderExpirationTime) {
168 + node.childExpirationTime = renderExpirationTime;
169 + if (
170 + alternate !== null &&
171 + alternate.childExpirationTime < renderExpirationTime
172 + ) {
173 + alternate.childExpirationTime = renderExpirationTime;
174 + }
175 + } else if (
176 + alternate !== null &&
177 + alternate.childExpirationTime < renderExpirationTime
178 + ) {
179 + alternate.childExpirationTime = renderExpirationTime;
180 + } else {
181 + // Neither alternate was updated, which means the rest of the
182 + // ancestor path already has sufficient priority.
183 + break;
184 + }
185 + node = node.return;
186 + }
187 +}
188 +
189 export function propagateContextChange(
190 workInProgress: Fiber,
191 context: ReactContext<mixed>,
@@ -199,31 +235,8 @@ export function propagateContextChange(
235 ) {
236 alternate.expirationTime = renderExpirationTime;
237 }
202 - // Update the child expiration time of all the ancestors, including
203 - // the alternates.
204 - let node = fiber.return;
205 - while (node !== null) {
206 - alternate = node.alternate;
207 - if (node.childExpirationTime < renderExpirationTime) {
208 - node.childExpirationTime = renderExpirationTime;
209 - if (
210 - alternate !== null &&
211 - alternate.childExpirationTime < renderExpirationTime
212 - ) {
213 - alternate.childExpirationTime = renderExpirationTime;
214 - }
215 - } else if (
216 - alternate !== null &&
217 - alternate.childExpirationTime < renderExpirationTime
218 - ) {
219 - alternate.childExpirationTime = renderExpirationTime;
220 - } else {
221 - // Neither alternate was updated, which means the rest of the
222 - // ancestor path already has sufficient priority.
223 - break;
224 - }
225 - node = node.return;
226 - }
238 +
239 + scheduleWorkOnParentPath(fiber.return, renderExpirationTime);
240
241 // Mark the expiration time on the list, too.
242 if (list.expirationTime < renderExpirationTime) {
@@ -239,6 +252,29 @@ export function propagateContextChange(
252 } else if (fiber.tag === ContextProvider) {
253 // Don't scan deeper if this is a matching provider
254 nextFiber = fiber.type === workInProgress.type ? null : fiber.child;
255 + } else if (
256 + enableSuspenseServerRenderer &&
257 + fiber.tag === DehydratedSuspenseComponent
258 + ) {
259 + // If a dehydrated suspense component is in this subtree, we don't know
260 + // if it will have any context consumers in it. The best we can do is
261 + // mark it as having updates on its children.
262 + if (fiber.expirationTime < renderExpirationTime) {
263 + fiber.expirationTime = renderExpirationTime;
264 + }
265 + let alternate = fiber.alternate;
266 + if (
267 + alternate !== null &&
268 + alternate.expirationTime < renderExpirationTime
269 + ) {
270 + alternate.expirationTime = renderExpirationTime;
271 + }
272 + // This is intentionally passing this fiber as the parent
273 + // because we want to schedule this fiber as having work
274 + // on its children. We'll use the childExpirationTime on
275 + // this fiber to indicate that a context has changed.
276 + scheduleWorkOnParentPath(fiber, renderExpirationTime);
277 + nextFiber = fiber.sibling;
278 } else {
279 // Traverse down.
280 nextFiber = fiber.child;
packages/react-reconciler/src/ReactFiberScheduler.js
+22 -2
@@ -60,6 +60,8 @@ import {
60 HostRoot,
61 MemoComponent,
62 SimpleMemoComponent,
63 + SuspenseComponent,
64 + DehydratedSuspenseComponent,
65 } from 'shared/ReactWorkTags';
66 import {
67 enableSchedulerTracing,
@@ -67,6 +69,7 @@ import {
69 enableUserTimingAPI,
70 replayFailedUnitOfWorkWithInvokeGuardedCallback,
71 warnAboutDeprecatedLifecycles,
72 + enableSuspenseServerRenderer,
73 } from 'shared/ReactFeatureFlags';
74 import getComponentName from 'shared/getComponentName';
75 import invariant from 'shared/invariant';
@@ -1697,8 +1700,25 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) {
1700 // resolved, which means at least part of the tree was likely unblocked. Try
1701 // rendering again, at a new expiration time.
1702
1700 - const retryCache: WeakSet<Thenable> | Set<Thenable> | null =
1701 - boundaryFiber.stateNode;
1703 + let retryCache: WeakSet<Thenable> | Set<Thenable> | null;
1704 + if (enableSuspenseServerRenderer) {
1705 + switch (boundaryFiber.tag) {
1706 + case SuspenseComponent:
1707 + retryCache = boundaryFiber.stateNode;
1708 + break;
1709 + case DehydratedSuspenseComponent:
1710 + retryCache = boundaryFiber.memoizedState;
1711 + break;
1712 + default:
1713 + invariant(
1714 + false,
1715 + 'Pinged unknown suspense boundary type. ' +
1716 + 'This is probably a bug in React.',
1717 + );
1718 + }
1719 + } else {
1720 + retryCache = boundaryFiber.stateNode;
1721 + }
1722 if (retryCache !== null) {
1723 // The thenable resolved, so we no longer need to memoize, because it will
1724 // never be thrown again.
packages/react-reconciler/src/ReactFiberUnwindWork.js
+95 -31
@@ -25,6 +25,7 @@ import {
25 HostPortal,
26 ContextProvider,
27 SuspenseComponent,
28 + DehydratedSuspenseComponent,
29 IncompleteClassComponent,
30 } from 'shared/ReactWorkTags';
31 import {
@@ -34,7 +35,10 @@ import {
35 ShouldCapture,
36 LifecycleEffectMask,
37 } from 'shared/ReactSideEffectTags';
37 -import {enableSchedulerTracing} from 'shared/ReactFeatureFlags';
38 +import {
39 + enableSchedulerTracing,
40 + enableSuspenseServerRenderer,
41 +} from 'shared/ReactFeatureFlags';
42 import {ConcurrentMode} from './ReactTypeOfMode';
43 import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent';
44
@@ -62,6 +66,7 @@ import {
66 markLegacyErrorBoundaryAsFailed,
67 isAlreadyFailedLegacyErrorBoundary,
68 pingSuspendedRoot,
69 + retryTimedOutBoundary,
70 } from './ReactFiberScheduler';
71
72 import invariant from 'shared/invariant';
@@ -73,6 +78,7 @@ import {
78 } from './ReactFiberExpirationTime';
79 import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority';
80
81 +const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
82 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
83
84 function createRootErrorUpdate(
@@ -144,6 +150,43 @@ function createClassErrorUpdate(
150 return update;
151 }
152
153 +function attachPingListener(
154 + root: FiberRoot,
155 + renderExpirationTime: ExpirationTime,
156 + thenable: Thenable,
157 +) {
158 + // Attach a listener to the promise to "ping" the root and retry. But
159 + // only if one does not already exist for the current render expiration
160 + // time (which acts like a "thread ID" here).
161 + let pingCache = root.pingCache;
162 + let threadIDs;
163 + if (pingCache === null) {
164 + pingCache = root.pingCache = new PossiblyWeakMap();
165 + threadIDs = new Set();
166 + pingCache.set(thenable, threadIDs);
167 + } else {
168 + threadIDs = pingCache.get(thenable);
169 + if (threadIDs === undefined) {
170 + threadIDs = new Set();
171 + pingCache.set(thenable, threadIDs);
172 + }
173 + }
174 + if (!threadIDs.has(renderExpirationTime)) {
175 + // Memoize using the thread ID to prevent redundant listeners.
176 + threadIDs.add(renderExpirationTime);
177 + let ping = pingSuspendedRoot.bind(
178 + null,
179 + root,
180 + thenable,
181 + renderExpirationTime,
182 + );
183 + if (enableSchedulerTracing) {
184 + ping = Schedule_tracing_wrap(ping);
185 + }
186 + thenable.then(ping, ping);
187 + }
188 +}
189 +
190 function throwException(
191 root: FiberRoot,
192 returnFiber: Fiber,
@@ -198,6 +241,9 @@ function throwException(
241 }
242 }
243 }
244 + // If there is a DehydratedSuspenseComponent we don't have to do anything because
245 + // if something suspends inside it, we will simply leave that as dehydrated. It
246 + // will never timeout.
247 workInProgress = workInProgress.return;
248 } while (workInProgress !== null);
249
@@ -265,36 +311,7 @@ function throwException(
311 // Confirmed that the boundary is in a concurrent mode tree. Continue
312 // with the normal suspend path.
313
268 - // Attach a listener to the promise to "ping" the root and retry. But
269 - // only if one does not already exist for the current render expiration
270 - // time (which acts like a "thread ID" here).
271 - let pingCache = root.pingCache;
272 - let threadIDs;
273 - if (pingCache === null) {
274 - pingCache = root.pingCache = new PossiblyWeakMap();
275 - threadIDs = new Set();
276 - pingCache.set(thenable, threadIDs);
277 - } else {
278 - threadIDs = pingCache.get(thenable);
279 - if (threadIDs === undefined) {
280 - threadIDs = new Set();
281 - pingCache.set(thenable, threadIDs);
282 - }
283 - }
284 - if (!threadIDs.has(renderExpirationTime)) {
285 - // Memoize using the thread ID to prevent redundant listeners.
286 - threadIDs.add(renderExpirationTime);
287 - let ping = pingSuspendedRoot.bind(
288 - null,
289 - root,
290 - thenable,
291 - renderExpirationTime,
292 - );
293 - if (enableSchedulerTracing) {
294 - ping = Schedule_tracing_wrap(ping);
295 - }
296 - thenable.then(ping, ping);
297 - }
314 + attachPingListener(root, renderExpirationTime, thenable);
315
316 let absoluteTimeoutMs;
317 if (earliestTimeoutMs === -1) {
@@ -331,6 +348,40 @@ function throwException(
348 // whole tree.
349 renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
350
351 + workInProgress.effectTag |= ShouldCapture;
352 + workInProgress.expirationTime = renderExpirationTime;
353 + return;
354 + } else if (
355 + enableSuspenseServerRenderer &&
356 + workInProgress.tag === DehydratedSuspenseComponent
357 + ) {
358 + attachPingListener(root, renderExpirationTime, thenable);
359 +
360 + // Since we already have a current fiber, we can eagerly add a retry listener.
361 + let retryCache = workInProgress.memoizedState;
362 + if (retryCache === null) {
363 + retryCache = workInProgress.memoizedState = new PossiblyWeakSet();
364 + const current = workInProgress.alternate;
365 + invariant(
366 + current,
367 + 'A dehydrated suspense boundary must commit before trying to render. ' +
368 + 'This is probably a bug in React.',
369 + );
370 + current.memoizedState = retryCache;
371 + }
372 + // Memoize using the boundary fiber to prevent redundant listeners.
373 + if (!retryCache.has(thenable)) {
374 + retryCache.add(thenable);
375 + let retry = retryTimedOutBoundary.bind(
376 + null,
377 + workInProgress,
378 + thenable,
379 + );
380 + if (enableSchedulerTracing) {
381 + retry = Schedule_tracing_wrap(retry);
382 + }
383 + thenable.then(retry, retry);
384 + }
385 workInProgress.effectTag |= ShouldCapture;
386 workInProgress.expirationTime = renderExpirationTime;
387 return;
@@ -432,6 +483,7 @@ function unwindWork(
483 return workInProgress;
484 }
485 case HostComponent: {
486 + // TODO: popHydrationState
487 popHostContext(workInProgress);
488 return null;
489 }
@@ -444,6 +496,18 @@ function unwindWork(
496 }
497 return null;
498 }
499 + case DehydratedSuspenseComponent: {
500 + if (enableSuspenseServerRenderer) {
501 + // TODO: popHydrationState
502 + const effectTag = workInProgress.effectTag;
503 + if (effectTag & ShouldCapture) {
504 + workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
505 + // Captured a suspense effect. Re-render the boundary.
506 + return workInProgress;
507 + }
508 + }
509 + return null;
510 + }
511 case HostPortal:
512 popHostContainer(workInProgress);
513 return null;
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+12
@@ -29,6 +29,7 @@ export opaque type Props = mixed; // eslint-disable-line no-undef
29 export opaque type Container = mixed; // eslint-disable-line no-undef
30 export opaque type Instance = mixed; // eslint-disable-line no-undef
31 export opaque type TextInstance = mixed; // eslint-disable-line no-undef
32 +export opaque type SuspenseInstance = mixed; // eslint-disable-line no-undef
33 export opaque type HydratableInstance = mixed; // eslint-disable-line no-undef
34 export opaque type PublicInstance = mixed; // eslint-disable-line no-undef
35 export opaque type HostContext = mixed; // eslint-disable-line no-undef
@@ -104,10 +105,17 @@ export const createHiddenTextInstance = $$$hostConfig.createHiddenTextInstance;
105 // -------------------
106 export const canHydrateInstance = $$$hostConfig.canHydrateInstance;
107 export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance;
108 +export const canHydrateSuspenseInstance =
109 + $$$hostConfig.canHydrateSuspenseInstance;
110 export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
111 export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild;
112 export const hydrateInstance = $$$hostConfig.hydrateInstance;
113 export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance;
114 +export const getNextHydratableInstanceAfterSuspenseInstance =
115 + $$$hostConfig.getNextHydratableInstanceAfterSuspenseInstance;
116 +export const clearSuspenseBoundary = $$$hostConfig.clearSuspenseBoundary;
117 +export const clearSuspenseBoundaryFromContainer =
118 + $$$hostConfig.clearSuspenseBoundaryFromContainer;
119 export const didNotMatchHydratedContainerTextInstance =
120 $$$hostConfig.didNotMatchHydratedContainerTextInstance;
121 export const didNotMatchHydratedTextInstance =
@@ -119,7 +127,11 @@ export const didNotFindHydratableContainerInstance =
127 $$$hostConfig.didNotFindHydratableContainerInstance;
128 export const didNotFindHydratableContainerTextInstance =
129 $$$hostConfig.didNotFindHydratableContainerTextInstance;
130 +export const didNotFindHydratableContainerSuspenseInstance =
131 + $$$hostConfig.didNotFindHydratableContainerSuspenseInstance;
132 export const didNotFindHydratableInstance =
133 $$$hostConfig.didNotFindHydratableInstance;
134 export const didNotFindHydratableTextInstance =
135 $$$hostConfig.didNotFindHydratableTextInstance;
136 +export const didNotFindHydratableSuspenseInstance =
137 + $$$hostConfig.didNotFindHydratableSuspenseInstance;
packages/shared/HostConfigWithNoHydration.js
+7
@@ -22,18 +22,25 @@ function shim(...args: any) {
22 }
23
24 // Hydration (when unsupported)
25 +export type SuspenseInstance = mixed;
26 export const supportsHydration = false;
27 export const canHydrateInstance = shim;
28 export const canHydrateTextInstance = shim;
29 +export const canHydrateSuspenseInstance = shim;
30 export const getNextHydratableSibling = shim;
31 export const getFirstHydratableChild = shim;
32 export const hydrateInstance = shim;
33 export const hydrateTextInstance = shim;
34 +export const getNextHydratableInstanceAfterSuspenseInstance = shim;
35 +export const clearSuspenseBoundary = shim;
36 +export const clearSuspenseBoundaryFromContainer = shim;
37 export const didNotMatchHydratedContainerTextInstance = shim;
38 export const didNotMatchHydratedTextInstance = shim;
39 export const didNotHydrateContainerInstance = shim;
40 export const didNotHydrateInstance = shim;
41 export const didNotFindHydratableContainerInstance = shim;
42 export const didNotFindHydratableContainerTextInstance = shim;
43 +export const didNotFindHydratableContainerSuspenseInstance = shim;
44 export const didNotFindHydratableInstance = shim;
45 export const didNotFindHydratableTextInstance = shim;
46 +export const didNotFindHydratableSuspenseInstance = shim;
packages/shared/ReactWorkTags.js
+1
@@ -46,3 +46,4 @@ export const MemoComponent = 14;
46 export const SimpleMemoComponent = 15;
47 export const LazyComponent = 16;
48 export const IncompleteClassComponent = 17;
49 +export const DehydratedSuspenseComponent = 18;