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
This commit is contained in:
Sebastian Markbåge 2019-02-11 21:25:44 -08:00 committed by GitHub
parent f24a0da6e0
commit f3a14951ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1417 additions and 130 deletions

View File

@ -1,17 +1,32 @@
import React, {Component} from 'react'; import React, {useContext, useState, Suspense} from 'react';
import Chrome from './Chrome'; import Chrome from './Chrome';
import Page from './Page'; import Page from './Page';
import Page2 from './Page2';
import Theme from './Theme';
export default class App extends Component { function LoadingIndicator() {
render() { let theme = useContext(Theme);
return ( return <div className={theme + '-loading'}>Loading...</div>;
<Chrome title="Hello World" assets={this.props.assets}> }
<div>
<h1>Hello World</h1> export default function App({assets}) {
<Page /> let [CurrentPage, switchPage] = useState(() => Page);
</div> return (
</Chrome> <Chrome title="Hello World" assets={assets}>
); <div>
} <h1>Hello World</h1>
<a className="link" onClick={() => switchPage(() => Page)}>
Page 1
</a>
{' | '}
<a className="link" onClick={() => switchPage(() => Page2)}>
Page 2
</a>
<Suspense fallback={<LoadingIndicator />}>
<CurrentPage />
</Suspense>
</div>
</Chrome>
);
} }

View File

@ -3,3 +3,27 @@ body {
padding: 0; padding: 0;
font-family: sans-serif; font-family: sans-serif;
} }
body.light {
background-color: #FFFFFF;
color: #333333;
}
body.dark {
background-color: #000000;
color: #CCCCCC;
}
.light-loading {
margin: 10px 0;
padding: 10px;
background-color: #CCCCCC;
color: #666666;
}
.dark-loading {
margin: 10px 0;
padding: 10px;
background-color: #333333;
color: #999999;
}

View File

@ -1,8 +1,11 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import Theme, {ThemeToggleButton} from './Theme';
import './Chrome.css'; import './Chrome.css';
export default class Chrome extends Component { export default class Chrome extends Component {
state = {theme: 'light'};
render() { render() {
const assets = this.props.assets; const assets = this.props.assets;
return ( return (
@ -14,13 +17,18 @@ export default class Chrome extends Component {
<link rel="stylesheet" href={assets['main.css']} /> <link rel="stylesheet" href={assets['main.css']} />
<title>{this.props.title}</title> <title>{this.props.title}</title>
</head> </head>
<body> <body className={this.state.theme}>
<noscript <noscript
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`, __html: `<b>Enable JavaScript to run this app.</b>`,
}} }}
/> />
{this.props.children} <Theme.Provider value={this.state.theme}>
{this.props.children}
<div>
<ThemeToggleButton onChange={theme => this.setState({theme})} />
</div>
</Theme.Provider>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `assetManifest = ${JSON.stringify(assets)};`, __html: `assetManifest = ${JSON.stringify(assets)};`,

View File

@ -1,3 +1,16 @@
.bold { .link {
font-weight: bold; font-weight: bold;
cursor: pointer;
}
.light-box {
margin: 10px 0;
padding: 10px;
background-color: #CCCCCC;
color: #333333;
}
.dark-box {
margin: 10px 0;
padding: 10px;
background-color: #333333;
color: #CCCCCC;
} }

View File

@ -1,5 +1,8 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import Theme from './Theme';
import Suspend from './Suspend';
import './Page.css'; import './Page.css';
const autofocusedInputs = [ const autofocusedInputs = [
@ -14,17 +17,22 @@ export default class Page extends Component {
}; };
render() { render() {
const link = ( const link = (
<a className="bold" onClick={this.handleClick}> <a className="link" onClick={this.handleClick}>
Click Here Click Here
</a> </a>
); );
return ( return (
<div> <div className={this.context + '-box'}>
<p suppressHydrationWarning={true}>A random number: {Math.random()}</p> <Suspend>
<p>Autofocus on page load: {autofocusedInputs}</p> <p suppressHydrationWarning={true}>
<p>{!this.state.active ? link : 'Thanks!'}</p> A random number: {Math.random()}
{this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>} </p>
<p>Autofocus on page load: {autofocusedInputs}</p>
<p>{!this.state.active ? link : 'Thanks!'}</p>
{this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>}
</Suspend>
</div> </div>
); );
} }
} }
Page.contextType = Theme;

View File

@ -0,0 +1,15 @@
import React, {useContext} from 'react';
import Theme from './Theme';
import Suspend from './Suspend';
import './Page.css';
export default function Page2() {
let theme = useContext(Theme);
return (
<div className={theme + '-box'}>
<Suspend>Content of a different page</Suspend>
</div>
);
}

View File

@ -0,0 +1,21 @@
let promise = null;
let isResolved = false;
export default function Suspend({children}) {
// This will suspend the content from rendering but only on the client.
// This is used to demo a slow loading app.
if (typeof window === 'object') {
if (!isResolved) {
if (promise === null) {
promise = new Promise(resolve => {
setTimeout(() => {
isResolved = true;
resolve();
}, 6000);
});
}
throw promise;
}
}
return children;
}

View File

@ -0,0 +1,25 @@
import React, {createContext, useContext, useState} from 'react';
const Theme = createContext('light');
export default Theme;
export function ThemeToggleButton({onChange}) {
let theme = useContext(Theme);
let [targetTheme, setTargetTheme] = useState(theme);
function toggleTheme() {
let newTheme = theme === 'light' ? 'dark' : 'light';
// High pri, responsive update.
setTargetTheme(newTheme);
// Perform the actual theme change in a separate update.
setTimeout(() => onChange(newTheme), 0);
}
if (targetTheme !== theme) {
return 'Switching to ' + targetTheme + '...';
}
return (
<a className="link" onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} theme
</a>
);
}

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import {hydrate} from 'react-dom'; import {unstable_createRoot} from 'react-dom';
import App from './components/App'; import App from './components/App';
hydrate(<App assets={window.assetManifest} />, document); let root = unstable_createRoot(document, {hydrate: true});
root.render(<App assets={window.assetManifest} />);

View File

@ -0,0 +1,637 @@
/**
* 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';
let React;
let ReactDOM;
let ReactDOMServer;
let ReactFeatureFlags;
let Suspense;
let act;
describe('ReactDOMServerPartialHydration', () => {
beforeEach(() => {
jest.resetModuleRegistry();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableSuspenseServerRenderer = true;
React = require('react');
ReactDOM = require('react-dom');
act = require('react-dom/test-utils').act;
ReactDOMServer = require('react-dom/server');
Suspense = React.Suspense;
});
it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
</Suspense>
</div>
);
}
// First we render the final HTML. With the streaming renderer
// this may have suspense points on the server but here we want
// to test the completed HTML. Don't suspend on the server.
suspend = false;
let finalHTML = ReactDOMServer.renderToString(<App />);
let container = document.createElement('div');
container.innerHTML = finalHTML;
let span = container.getElementsByTagName('span')[0];
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App />);
jest.runAllTimers();
expect(ref.current).toBe(null);
// Resolving the promise should continue hydration
suspend = false;
resolve();
await promise;
jest.runAllTimers();
// We should now have hydrated with a ref on the existing span.
expect(ref.current).toBe(span);
});
it('can insert siblings before the dehydrated boundary', () => {
let suspend = false;
let promise = new Promise(() => {});
let showSibling;
function Child() {
if (suspend) {
throw promise;
} else {
return 'Second';
}
}
function Sibling() {
let [visible, setVisibilty] = React.useState(false);
showSibling = () => setVisibilty(true);
if (visible) {
return <div>First</div>;
}
return null;
}
function App() {
return (
<div>
<Sibling />
<Suspense fallback="Loading...">
<span>
<Child />
</span>
</Suspense>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(<App />);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
act(() => {
ReactDOM.hydrate(<App />, container);
});
expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
// In this state, we can still update the siblings.
act(() => showSibling());
expect(container.firstChild.firstChild.tagName).toBe('DIV');
expect(container.firstChild.firstChild.textContent).toBe('First');
});
it('can delete the dehydrated boundary before it is hydrated', () => {
let suspend = false;
let promise = new Promise(() => {});
let hideMiddle;
function Child() {
if (suspend) {
throw promise;
} else {
return (
<React.Fragment>
<div>Middle</div>
Some text
</React.Fragment>
);
}
}
function App() {
let [visible, setVisibilty] = React.useState(true);
hideMiddle = () => setVisibilty(false);
return (
<div>
<div>Before</div>
{visible ? (
<Suspense fallback="Loading...">
<Child />
</Suspense>
) : null}
<div>After</div>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(<App />);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
act(() => {
ReactDOM.hydrate(<App />, container);
});
expect(container.firstChild.children[1].textContent).toBe('Middle');
// In this state, we can still delete the boundary.
act(() => hideMiddle());
expect(container.firstChild.children[1].textContent).toBe('After');
});
it('regenerates the content if props have changed before hydration completes', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
let span = container.getElementsByTagName('span')[0];
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App text="Hello" className="hello" />);
jest.runAllTimers();
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
// Render an update, which will be higher or the same priority as pinging the hydration.
root.render(<App text="Hi" className="hi" />);
// At the same time, resolving the promise so that rendering can complete.
suspend = false;
resolve();
await promise;
// Flushing both of these in the same batch won't be able to hydrate so we'll
// probably throw away the existing subtree.
jest.runAllTimers();
// Pick up the new span. In an ideal implementation this might be the same span
// but patched up. At the time of writing, this will be a new span though.
span = container.getElementsByTagName('span')[0];
// We should now have fully rendered with a ref on the new span.
expect(ref.current).toBe(span);
expect(span.textContent).toBe('Hi');
// If we ended up hydrating the existing content, we won't have properly
// patched up the tree, which might mean we haven't patched the className.
expect(span.className).toBe('hi');
});
it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App text="Hello" className="hello" />);
jest.runAllTimers();
expect(ref.current).toBe(null);
// Render an update, but leave it still suspended.
root.render(<App text="Hi" className="hi" />);
// Flushing now should delete the existing content and show the fallback.
jest.runAllTimers();
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
// Unsuspending shows the content.
suspend = false;
resolve();
await promise;
jest.runAllTimers();
let span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
it('shows the fallback of the outer if fallback is missing', async () => {
// This is the same exact test as above but with a nested Suspense without a fallback.
// This should be a noop.
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Suspense maxDuration={200}>
<Child text={text} />
</Suspense>
</span>
</Suspense>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App text="Hello" className="hello" />);
jest.runAllTimers();
expect(ref.current).toBe(null);
// Render an update, but leave it still suspended.
root.render(<App text="Hi" className="hi" />);
// Flushing now should delete the existing content and show the fallback.
jest.runAllTimers();
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
// Unsuspending shows the content.
suspend = false;
resolve();
await promise;
jest.runAllTimers();
let span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
it('clears nested suspense boundaries if they did not hydrate yet', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<Suspense fallback="Never happens">
<Child text={text} />
</Suspense>{' '}
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App text="Hello" className="hello" />);
jest.runAllTimers();
expect(ref.current).toBe(null);
// Render an update, but leave it still suspended.
root.render(<App text="Hi" className="hi" />);
// Flushing now should delete the existing content and show the fallback.
jest.runAllTimers();
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
// Unsuspending shows the content.
suspend = false;
resolve();
await promise;
jest.runAllTimers();
let span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi Hi');
});
it('regenerates the content if context has changed before hydration completes', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
let Context = React.createContext(null);
function Child() {
let {text, className} = React.useContext(Context);
if (suspend) {
throw promise;
} else {
return (
<span ref={ref} className={className}>
{text}
</span>
);
}
}
const App = React.memo(function App() {
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
});
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
let span = container.getElementsByTagName('span')[0];
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
jest.runAllTimers();
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
// Render an update, which will be higher or the same priority as pinging the hydration.
root.render(
<Context.Provider value={{text: 'Hi', className: 'hi'}}>
<App />
</Context.Provider>,
);
// At the same time, resolving the promise so that rendering can complete.
suspend = false;
resolve();
await promise;
// Flushing both of these in the same batch won't be able to hydrate so we'll
// probably throw away the existing subtree.
jest.runAllTimers();
// Pick up the new span. In an ideal implementation this might be the same span
// but patched up. At the time of writing, this will be a new span though.
span = container.getElementsByTagName('span')[0];
// We should now have fully rendered with a ref on the new span.
expect(ref.current).toBe(span);
expect(span.textContent).toBe('Hi');
// If we ended up hydrating the existing content, we won't have properly
// patched up the tree, which might mean we haven't patched the className.
expect(span.className).toBe('hi');
});
it('shows the fallback if context has changed before hydration completes and is still suspended', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let ref = React.createRef();
let Context = React.createContext(null);
function Child() {
let {text, className} = React.useContext(Context);
if (suspend) {
throw promise;
} else {
return (
<span ref={ref} className={className}>
{text}
</span>
);
}
}
const App = React.memo(function App() {
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
});
suspend = false;
let finalHTML = ReactDOMServer.renderToString(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
jest.runAllTimers();
expect(ref.current).toBe(null);
// Render an update, but leave it still suspended.
root.render(
<Context.Provider value={{text: 'Hi', className: 'hi'}}>
<App />
</Context.Provider>,
);
// Flushing now should delete the existing content and show the fallback.
jest.runAllTimers();
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
// Unsuspending shows the content.
suspend = false;
resolve();
await promise;
jest.runAllTimers();
let span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
});

View File

@ -52,38 +52,47 @@ describe('ReactDOMServerSuspense', () => {
} }
it('should render the children when no promise is thrown', async () => { it('should render the children when no promise is thrown', async () => {
const e = await serverRender( const c = await serverRender(
<React.Suspense fallback={<Text text="Fallback" />}> <div>
<Text text="Children" /> <React.Suspense fallback={<Text text="Fallback" />}>
</React.Suspense>, <Text text="Children" />
</React.Suspense>
</div>,
); );
const e = c.children[0];
expect(e.tagName).toBe('DIV'); expect(e.tagName).toBe('DIV');
expect(e.textContent).toBe('Children'); expect(e.textContent).toBe('Children');
}); });
it('should render the fallback when a promise thrown', async () => { it('should render the fallback when a promise thrown', async () => {
const e = await serverRender( const c = await serverRender(
<React.Suspense fallback={<Text text="Fallback" />}> <div>
<AsyncText text="Children" /> <React.Suspense fallback={<Text text="Fallback" />}>
</React.Suspense>, <AsyncText text="Children" />
</React.Suspense>
</div>,
); );
const e = c.children[0];
expect(e.tagName).toBe('DIV'); expect(e.tagName).toBe('DIV');
expect(e.textContent).toBe('Fallback'); expect(e.textContent).toBe('Fallback');
}); });
it('should work with nested suspense components', async () => { it('should work with nested suspense components', async () => {
const e = await serverRender( const c = await serverRender(
<React.Suspense fallback={<Text text="Fallback" />}> <div>
<div> <React.Suspense fallback={<Text text="Fallback" />}>
<Text text="Children" /> <div>
<React.Suspense fallback={<Text text="Fallback" />}> <Text text="Children" />
<AsyncText text="Children" /> <React.Suspense fallback={<Text text="Fallback" />}>
</React.Suspense> <AsyncText text="Children" />
</div> </React.Suspense>
</React.Suspense>, </div>
</React.Suspense>
</div>,
); );
const e = c.children[0];
expect(e.innerHTML).toBe('<div>Children</div><div>Fallback</div>'); expect(e.innerHTML).toBe('<div>Children</div><div>Fallback</div>');
}); });

View File

@ -56,7 +56,8 @@ export type Props = {
export type Container = Element | Document; export type Container = Element | Document;
export type Instance = Element; export type Instance = Element;
export type TextInstance = Text; export type TextInstance = Text;
export type HydratableInstance = Element | Text; export type SuspenseInstance = Comment;
export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
export type PublicInstance = Element | Text; export type PublicInstance = Element | Text;
type HostContextDev = { type HostContextDev = {
namespace: string, namespace: string,
@ -73,6 +74,7 @@ import {
unstable_scheduleCallback as scheduleDeferredCallback, unstable_scheduleCallback as scheduleDeferredCallback,
unstable_cancelCallback as cancelDeferredCallback, unstable_cancelCallback as cancelDeferredCallback,
} from 'scheduler'; } from 'scheduler';
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
export { export {
unstable_now as now, unstable_now as now,
unstable_scheduleCallback as scheduleDeferredCallback, unstable_scheduleCallback as scheduleDeferredCallback,
@ -85,6 +87,9 @@ if (__DEV__) {
SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning';
} }
const SUSPENSE_START_DATA = '$';
const SUSPENSE_END_DATA = '/$';
const STYLE = 'style'; const STYLE = 'style';
let eventsEnabled: ?boolean = null; let eventsEnabled: ?boolean = null;
@ -397,7 +402,7 @@ export function appendChildToContainer(
export function insertBefore( export function insertBefore(
parentInstance: Instance, parentInstance: Instance,
child: Instance | TextInstance, child: Instance | TextInstance,
beforeChild: Instance | TextInstance, beforeChild: Instance | TextInstance | SuspenseInstance,
): void { ): void {
parentInstance.insertBefore(child, beforeChild); parentInstance.insertBefore(child, beforeChild);
} }
@ -405,7 +410,7 @@ export function insertBefore(
export function insertInContainerBefore( export function insertInContainerBefore(
container: Container, container: Container,
child: Instance | TextInstance, child: Instance | TextInstance,
beforeChild: Instance | TextInstance, beforeChild: Instance | TextInstance | SuspenseInstance,
): void { ): void {
if (container.nodeType === COMMENT_NODE) { if (container.nodeType === COMMENT_NODE) {
(container.parentNode: any).insertBefore(child, beforeChild); (container.parentNode: any).insertBefore(child, beforeChild);
@ -416,14 +421,14 @@ export function insertInContainerBefore(
export function removeChild( export function removeChild(
parentInstance: Instance, parentInstance: Instance,
child: Instance | TextInstance, child: Instance | TextInstance | SuspenseInstance,
): void { ): void {
parentInstance.removeChild(child); parentInstance.removeChild(child);
} }
export function removeChildFromContainer( export function removeChildFromContainer(
container: Container, container: Container,
child: Instance | TextInstance, child: Instance | TextInstance | SuspenseInstance,
): void { ): void {
if (container.nodeType === COMMENT_NODE) { if (container.nodeType === COMMENT_NODE) {
(container.parentNode: any).removeChild(child); (container.parentNode: any).removeChild(child);
@ -432,6 +437,49 @@ export function removeChildFromContainer(
} }
} }
export function clearSuspenseBoundary(
parentInstance: Instance,
suspenseInstance: SuspenseInstance,
): void {
let node = suspenseInstance;
// Delete all nodes within this suspense boundary.
// There might be nested nodes so we need to keep track of how
// deep we are and only break out when we're back on top.
let depth = 0;
do {
let nextNode = node.nextSibling;
parentInstance.removeChild(node);
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
let data = ((nextNode: any).data: string);
if (data === SUSPENSE_END_DATA) {
if (depth === 0) {
parentInstance.removeChild(nextNode);
return;
} else {
depth--;
}
} else if (data === SUSPENSE_START_DATA) {
depth++;
}
}
node = nextNode;
} while (node);
// TODO: Warn, we didn't find the end comment boundary.
}
export function clearSuspenseBoundaryFromContainer(
container: Container,
suspenseInstance: SuspenseInstance,
): void {
if (container.nodeType === COMMENT_NODE) {
clearSuspenseBoundary((container.parentNode: any), suspenseInstance);
} else if (container.nodeType === ELEMENT_NODE) {
clearSuspenseBoundary((container: any), suspenseInstance);
} else {
// Document nodes should never contain suspense boundaries.
}
}
export function hideInstance(instance: Instance): void { export function hideInstance(instance: Instance): void {
// TODO: Does this work for all element types? What about MathML? Should we // TODO: Does this work for all element types? What about MathML? Should we
// pass host context to this method? // pass host context to this method?
@ -469,7 +517,7 @@ export function unhideTextInstance(
export const supportsHydration = true; export const supportsHydration = true;
export function canHydrateInstance( export function canHydrateInstance(
instance: Instance | TextInstance, instance: HydratableInstance,
type: string, type: string,
props: Props, props: Props,
): null | Instance { ): null | Instance {
@ -484,7 +532,7 @@ export function canHydrateInstance(
} }
export function canHydrateTextInstance( export function canHydrateTextInstance(
instance: Instance | TextInstance, instance: HydratableInstance,
text: string, text: string,
): null | TextInstance { ): null | TextInstance {
if (text === '' || instance.nodeType !== TEXT_NODE) { if (text === '' || instance.nodeType !== TEXT_NODE) {
@ -495,15 +543,29 @@ export function canHydrateTextInstance(
return ((instance: any): TextInstance); return ((instance: any): TextInstance);
} }
export function canHydrateSuspenseInstance(
instance: HydratableInstance,
): null | SuspenseInstance {
if (instance.nodeType !== COMMENT_NODE) {
// Empty strings are not parsed by HTML so there won't be a correct match here.
return null;
}
// This has now been refined to a suspense node.
return ((instance: any): SuspenseInstance);
}
export function getNextHydratableSibling( export function getNextHydratableSibling(
instance: Instance | TextInstance, instance: HydratableInstance,
): null | Instance | TextInstance { ): null | HydratableInstance {
let node = instance.nextSibling; let node = instance.nextSibling;
// Skip non-hydratable nodes. // Skip non-hydratable nodes.
while ( while (
node && node &&
node.nodeType !== ELEMENT_NODE && node.nodeType !== ELEMENT_NODE &&
node.nodeType !== TEXT_NODE node.nodeType !== TEXT_NODE &&
(!enableSuspenseServerRenderer ||
node.nodeType !== COMMENT_NODE ||
(node: any).data !== SUSPENSE_START_DATA)
) { ) {
node = node.nextSibling; node = node.nextSibling;
} }
@ -512,13 +574,16 @@ export function getNextHydratableSibling(
export function getFirstHydratableChild( export function getFirstHydratableChild(
parentInstance: Container | Instance, parentInstance: Container | Instance,
): null | Instance | TextInstance { ): null | HydratableInstance {
let next = parentInstance.firstChild; let next = parentInstance.firstChild;
// Skip non-hydratable nodes. // Skip non-hydratable nodes.
while ( while (
next && next &&
next.nodeType !== ELEMENT_NODE && next.nodeType !== ELEMENT_NODE &&
next.nodeType !== TEXT_NODE next.nodeType !== TEXT_NODE &&
(!enableSuspenseServerRenderer ||
next.nodeType !== COMMENT_NODE ||
(next: any).data !== SUSPENSE_START_DATA)
) { ) {
next = next.nextSibling; next = next.nextSibling;
} }
@ -562,6 +627,33 @@ export function hydrateTextInstance(
return diffHydratedText(textInstance, text); return diffHydratedText(textInstance, text);
} }
export function getNextHydratableInstanceAfterSuspenseInstance(
suspenseInstance: SuspenseInstance,
): null | HydratableInstance {
let node = suspenseInstance.nextSibling;
// Skip past all nodes within this suspense boundary.
// There might be nested nodes so we need to keep track of how
// deep we are and only break out when we're back on top.
let depth = 0;
while (node) {
if (node.nodeType === COMMENT_NODE) {
let data = ((node: any).data: string);
if (data === SUSPENSE_END_DATA) {
if (depth === 0) {
return getNextHydratableSibling((node: any));
} else {
depth--;
}
} else if (data === SUSPENSE_START_DATA) {
depth++;
}
}
node = node.nextSibling;
}
// TODO: Warn, we didn't find the end comment boundary.
return null;
}
export function didNotMatchHydratedContainerTextInstance( export function didNotMatchHydratedContainerTextInstance(
parentContainer: Container, parentContainer: Container,
textInstance: TextInstance, textInstance: TextInstance,
@ -586,11 +678,13 @@ export function didNotMatchHydratedTextInstance(
export function didNotHydrateContainerInstance( export function didNotHydrateContainerInstance(
parentContainer: Container, parentContainer: Container,
instance: Instance | TextInstance, instance: HydratableInstance,
) { ) {
if (__DEV__) { if (__DEV__) {
if (instance.nodeType === ELEMENT_NODE) { if (instance.nodeType === ELEMENT_NODE) {
warnForDeletedHydratableElement(parentContainer, (instance: any)); warnForDeletedHydratableElement(parentContainer, (instance: any));
} else if (instance.nodeType === COMMENT_NODE) {
// TODO: warnForDeletedHydratableSuspenseBoundary
} else { } else {
warnForDeletedHydratableText(parentContainer, (instance: any)); warnForDeletedHydratableText(parentContainer, (instance: any));
} }
@ -601,11 +695,13 @@ export function didNotHydrateInstance(
parentType: string, parentType: string,
parentProps: Props, parentProps: Props,
parentInstance: Instance, parentInstance: Instance,
instance: Instance | TextInstance, instance: HydratableInstance,
) { ) {
if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
if (instance.nodeType === ELEMENT_NODE) { if (instance.nodeType === ELEMENT_NODE) {
warnForDeletedHydratableElement(parentInstance, (instance: any)); warnForDeletedHydratableElement(parentInstance, (instance: any));
} else if (instance.nodeType === COMMENT_NODE) {
// TODO: warnForDeletedHydratableSuspenseBoundary
} else { } else {
warnForDeletedHydratableText(parentInstance, (instance: any)); warnForDeletedHydratableText(parentInstance, (instance: any));
} }
@ -631,6 +727,14 @@ export function didNotFindHydratableContainerTextInstance(
} }
} }
export function didNotFindHydratableContainerSuspenseInstance(
parentContainer: Container,
) {
if (__DEV__) {
// TODO: warnForInsertedHydratedSupsense(parentContainer);
}
}
export function didNotFindHydratableInstance( export function didNotFindHydratableInstance(
parentType: string, parentType: string,
parentProps: Props, parentProps: Props,
@ -653,3 +757,13 @@ export function didNotFindHydratableTextInstance(
warnForInsertedHydratedText(parentInstance, text); warnForInsertedHydratedText(parentInstance, text);
} }
} }
export function didNotFindHydratableSuspenseInstance(
parentType: string,
parentProps: Props,
parentInstance: Instance,
) {
if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
// TODO: warnForInsertedHydratedSuspense(parentInstance);
}
}

View File

@ -956,9 +956,27 @@ class ReactDOMServerRenderer {
} }
case REACT_SUSPENSE_TYPE: { case REACT_SUSPENSE_TYPE: {
if (enableSuspenseServerRenderer) { if (enableSuspenseServerRenderer) {
const fallbackChildren = toArray( const fallback = ((nextChild: any): ReactElement).props.fallback;
((nextChild: any): ReactElement).props.fallback, if (fallback === undefined) {
); // If there is no fallback, then this just behaves as a fragment.
const nextChildren = toArray(
((nextChild: any): ReactElement).props.children,
);
const frame: Frame = {
type: null,
domNamespace: parentNamespace,
children: nextChildren,
childIndex: 0,
context: context,
footer: '',
};
if (__DEV__) {
((frame: any): FrameDev).debugElementStack = [];
}
this.stack.push(frame);
return '';
}
const fallbackChildren = toArray(fallback);
const nextChildren = toArray( const nextChildren = toArray(
((nextChild: any): ReactElement).props.children, ((nextChild: any): ReactElement).props.children,
); );
@ -978,7 +996,7 @@ class ReactDOMServerRenderer {
children: nextChildren, children: nextChildren,
childIndex: 0, childIndex: 0,
context: context, context: context,
footer: '', footer: '<!--/$-->',
}; };
if (__DEV__) { if (__DEV__) {
((frame: any): FrameDev).debugElementStack = []; ((frame: any): FrameDev).debugElementStack = [];
@ -986,7 +1004,7 @@ class ReactDOMServerRenderer {
} }
this.stack.push(frame); this.stack.push(frame);
this.suspenseDepth++; this.suspenseDepth++;
return ''; return '<!--$-->';
} else { } else {
invariant(false, 'ReactDOMServer does not yet support Suspense.'); invariant(false, 'ReactDOMServer does not yet support Suspense.');
} }

View File

@ -21,6 +21,7 @@ import {
ContextConsumer, ContextConsumer,
Mode, Mode,
SuspenseComponent, SuspenseComponent,
DehydratedSuspenseComponent,
} from 'shared/ReactWorkTags'; } from 'shared/ReactWorkTags';
type MeasurementPhase = type MeasurementPhase =
@ -317,7 +318,8 @@ export function stopFailedWorkTimer(fiber: Fiber): void {
} }
fiber._debugIsCurrentlyTiming = false; fiber._debugIsCurrentlyTiming = false;
const warning = const warning =
fiber.tag === SuspenseComponent fiber.tag === SuspenseComponent ||
fiber.tag === DehydratedSuspenseComponent
? 'Rendering was suspended' ? 'Rendering was suspended'
: 'An error was thrown inside this error boundary'; : 'An error was thrown inside this error boundary';
endFiberMark(fiber, null, warning); endFiberMark(fiber, null, warning);

View File

@ -30,6 +30,7 @@ import {
ContextConsumer, ContextConsumer,
Profiler, Profiler,
SuspenseComponent, SuspenseComponent,
DehydratedSuspenseComponent,
MemoComponent, MemoComponent,
SimpleMemoComponent, SimpleMemoComponent,
LazyComponent, LazyComponent,
@ -43,12 +44,14 @@ import {
DidCapture, DidCapture,
Update, Update,
Ref, Ref,
Deletion,
} from 'shared/ReactSideEffectTags'; } from 'shared/ReactSideEffectTags';
import ReactSharedInternals from 'shared/ReactSharedInternals'; import ReactSharedInternals from 'shared/ReactSharedInternals';
import { import {
debugRenderPhaseSideEffects, debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode, debugRenderPhaseSideEffectsForStrictMode,
enableProfilerTimer, enableProfilerTimer,
enableSuspenseServerRenderer,
} from 'shared/ReactFeatureFlags'; } from 'shared/ReactFeatureFlags';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
import shallowEqual from 'shared/shallowEqual'; import shallowEqual from 'shared/shallowEqual';
@ -103,6 +106,7 @@ import {
} from './ReactFiberContext'; } from './ReactFiberContext';
import { import {
enterHydrationState, enterHydrationState,
reenterHydrationStateFromDehydratedSuspenseInstance,
resetHydrationState, resetHydrationState,
tryToClaimNextHydratableInstance, tryToClaimNextHydratableInstance,
} from './ReactFiberHydrationContext'; } from './ReactFiberHydrationContext';
@ -1392,6 +1396,22 @@ function updateSuspenseComponent(
// children -- we skip over the primary children entirely. // children -- we skip over the primary children entirely.
let next; let next;
if (current === null) { if (current === null) {
if (enableSuspenseServerRenderer) {
// If we're currently hydrating, try to hydrate this boundary.
// But only if this has a fallback.
if (nextProps.fallback !== undefined) {
tryToClaimNextHydratableInstance(workInProgress);
// This could've changed the tag if this was a dehydrated suspense component.
if (workInProgress.tag === DehydratedSuspenseComponent) {
return updateDehydratedSuspenseComponent(
null,
workInProgress,
renderExpirationTime,
);
}
}
}
// This is the initial mount. This branch is pretty simple because there's // This is the initial mount. This branch is pretty simple because there's
// no previous state that needs to be preserved. // no previous state that needs to be preserved.
if (nextDidTimeout) { if (nextDidTimeout) {
@ -1598,6 +1618,78 @@ function updateSuspenseComponent(
return next; return next;
} }
function updateDehydratedSuspenseComponent(
current: Fiber | null,
workInProgress: Fiber,
renderExpirationTime: ExpirationTime,
) {
if (current === null) {
// During the first pass, we'll bail out and not drill into the children.
// Instead, we'll leave the content in place and try to hydrate it later.
workInProgress.expirationTime = Never;
return null;
}
// We use childExpirationTime to indicate that a child might depend on context, so if
// any context has changed, we need to treat is as if the input might have changed.
const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
if (didReceiveUpdate || hasContextChanged) {
// This boundary has changed since the first render. This means that we are now unable to
// hydrate it. We might still be able to hydrate it using an earlier expiration time but
// during this render we can't. Instead, we're going to delete the whole subtree and
// instead inject a new real Suspense boundary to take its place, which may render content
// or fallback. The real Suspense boundary will suspend for a while so we have some time
// to ensure it can produce real content, but all state and pending events will be lost.
// Detach from the current dehydrated boundary.
current.alternate = null;
workInProgress.alternate = null;
// Insert a deletion in the effect list.
let returnFiber = workInProgress.return;
invariant(
returnFiber !== null,
'Suspense boundaries are never on the root. ' +
'This is probably a bug in React.',
);
const last = returnFiber.lastEffect;
if (last !== null) {
last.nextEffect = current;
returnFiber.lastEffect = current;
} else {
returnFiber.firstEffect = returnFiber.lastEffect = current;
}
current.nextEffect = null;
current.effectTag = Deletion;
// Upgrade this work in progress to a real Suspense component.
workInProgress.tag = SuspenseComponent;
workInProgress.stateNode = null;
workInProgress.memoizedState = null;
// This is now an insertion.
workInProgress.effectTag |= Placement;
// Retry as a real Suspense component.
return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
}
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
// This is the first attempt.
reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress);
const nextProps = workInProgress.pendingProps;
const nextChildren = nextProps.children;
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderExpirationTime,
);
return workInProgress.child;
} else {
// Something suspended. Leave the existing children in place.
// TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far?
workInProgress.child = null;
return null;
}
}
function updatePortalComponent( function updatePortalComponent(
current: Fiber | null, current: Fiber | null,
workInProgress: Fiber, workInProgress: Fiber,
@ -1882,6 +1974,15 @@ function beginWork(
} }
break; break;
} }
case DehydratedSuspenseComponent: {
if (enableSuspenseServerRenderer) {
// We know that this component will suspend again because if it has
// been unsuspended it has committed as a regular Suspense component.
// If it needs to be retried, it should have work scheduled on it.
workInProgress.effectTag |= DidCapture;
break;
}
}
} }
return bailoutOnAlreadyFinishedWork( return bailoutOnAlreadyFinishedWork(
current, current,
@ -2051,13 +2152,22 @@ function beginWork(
renderExpirationTime, renderExpirationTime,
); );
} }
default: case DehydratedSuspenseComponent: {
invariant( if (enableSuspenseServerRenderer) {
false, return updateDehydratedSuspenseComponent(
'Unknown unit of work tag. This error is likely caused by a bug in ' + current,
'React. Please file an issue.', workInProgress,
); renderExpirationTime,
);
}
break;
}
} }
invariant(
false,
'Unknown unit of work tag. This error is likely caused by a bug in ' +
'React. Please file an issue.',
);
} }
export {beginWork}; export {beginWork};

View File

@ -10,6 +10,7 @@
import type { import type {
Instance, Instance,
TextInstance, TextInstance,
SuspenseInstance,
Container, Container,
ChildSet, ChildSet,
UpdatePayload, UpdatePayload,
@ -26,6 +27,7 @@ import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
import { import {
enableSchedulerTracing, enableSchedulerTracing,
enableProfilerTimer, enableProfilerTimer,
enableSuspenseServerRenderer,
} from 'shared/ReactFeatureFlags'; } from 'shared/ReactFeatureFlags';
import { import {
FunctionComponent, FunctionComponent,
@ -37,6 +39,7 @@ import {
HostPortal, HostPortal,
Profiler, Profiler,
SuspenseComponent, SuspenseComponent,
DehydratedSuspenseComponent,
IncompleteClassComponent, IncompleteClassComponent,
MemoComponent, MemoComponent,
SimpleMemoComponent, SimpleMemoComponent,
@ -79,6 +82,8 @@ import {
insertInContainerBefore, insertInContainerBefore,
removeChild, removeChild,
removeChildFromContainer, removeChildFromContainer,
clearSuspenseBoundary,
clearSuspenseBoundaryFromContainer,
replaceContainerChildren, replaceContainerChildren,
createContainerChildSet, createContainerChildSet,
hideInstance, hideInstance,
@ -881,7 +886,11 @@ function getHostSibling(fiber: Fiber): ?Instance {
} }
node.sibling.return = node.return; node.sibling.return = node.return;
node = node.sibling; node = node.sibling;
while (node.tag !== HostComponent && node.tag !== HostText) { while (
node.tag !== HostComponent &&
node.tag !== HostText &&
node.tag !== DehydratedSuspenseComponent
) {
// If it is not host node and, we might have a host node inside it. // If it is not host node and, we might have a host node inside it.
// Try to search down until we find one. // Try to search down until we find one.
if (node.effectTag & Placement) { if (node.effectTag & Placement) {
@ -1032,11 +1041,33 @@ function unmountHostComponents(current): void {
// After all the children have unmounted, it is now safe to remove the // After all the children have unmounted, it is now safe to remove the
// node from the tree. // node from the tree.
if (currentParentIsContainer) { if (currentParentIsContainer) {
removeChildFromContainer((currentParent: any), node.stateNode); removeChildFromContainer(
((currentParent: any): Container),
(node.stateNode: Instance | TextInstance),
);
} else { } else {
removeChild((currentParent: any), node.stateNode); removeChild(
((currentParent: any): Instance),
(node.stateNode: Instance | TextInstance),
);
} }
// Don't visit children because we already visited them. // Don't visit children because we already visited them.
} else if (
enableSuspenseServerRenderer &&
node.tag === DehydratedSuspenseComponent
) {
// Delete the dehydrated suspense boundary and all of its content.
if (currentParentIsContainer) {
clearSuspenseBoundaryFromContainer(
((currentParent: any): Container),
(node.stateNode: SuspenseInstance),
);
} else {
clearSuspenseBoundary(
((currentParent: any): Instance),
(node.stateNode: SuspenseInstance),
);
}
} else if (node.tag === HostPortal) { } else if (node.tag === HostPortal) {
if (node.child !== null) { if (node.child !== null) {
// When we go into a portal, it becomes the parent to remove from. // When we go into a portal, it becomes the parent to remove from.

View File

@ -34,6 +34,7 @@ import {
Mode, Mode,
Profiler, Profiler,
SuspenseComponent, SuspenseComponent,
DehydratedSuspenseComponent,
MemoComponent, MemoComponent,
SimpleMemoComponent, SimpleMemoComponent,
LazyComponent, LazyComponent,
@ -80,8 +81,10 @@ import {popProvider} from './ReactFiberNewContext';
import { import {
prepareToHydrateHostInstance, prepareToHydrateHostInstance,
prepareToHydrateHostTextInstance, prepareToHydrateHostTextInstance,
skipPastDehydratedSuspenseInstance,
popHydrationState, popHydrationState,
} from './ReactFiberHydrationContext'; } from './ReactFiberHydrationContext';
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
function markUpdate(workInProgress: Fiber) { function markUpdate(workInProgress: Fiber) {
// Tag the fiber with an update effect. This turns a Placement into // Tag the fiber with an update effect. This turns a Placement into
@ -762,6 +765,29 @@ function completeWork(
} }
break; break;
} }
case DehydratedSuspenseComponent: {
if (enableSuspenseServerRenderer) {
if (current === null) {
let wasHydrated = popHydrationState(workInProgress);
invariant(
wasHydrated,
'A dehydrated suspense component was completed without a hydrated node. ' +
'This is probably a bug in React.',
);
skipPastDehydratedSuspenseInstance(workInProgress);
} else if ((workInProgress.effectTag & DidCapture) === NoEffect) {
// This boundary did not suspend so it's now hydrated.
// To handle any future suspense cases, we're going to now upgrade it
// to a Suspense component. We detach it from the existing current fiber.
current.alternate = null;
workInProgress.alternate = null;
workInProgress.tag = SuspenseComponent;
workInProgress.memoizedState = null;
workInProgress.stateNode = null;
}
}
break;
}
default: default:
invariant( invariant(
false, false,

View File

@ -12,11 +12,18 @@ import type {
Instance, Instance,
TextInstance, TextInstance,
HydratableInstance, HydratableInstance,
SuspenseInstance,
Container, Container,
HostContext, HostContext,
} from './ReactFiberHostConfig'; } from './ReactFiberHostConfig';
import {HostComponent, HostText, HostRoot} from 'shared/ReactWorkTags'; import {
HostComponent,
HostText,
HostRoot,
SuspenseComponent,
DehydratedSuspenseComponent,
} from 'shared/ReactWorkTags';
import {Deletion, Placement} from 'shared/ReactSideEffectTags'; import {Deletion, Placement} from 'shared/ReactSideEffectTags';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
@ -26,19 +33,24 @@ import {
supportsHydration, supportsHydration,
canHydrateInstance, canHydrateInstance,
canHydrateTextInstance, canHydrateTextInstance,
canHydrateSuspenseInstance,
getNextHydratableSibling, getNextHydratableSibling,
getFirstHydratableChild, getFirstHydratableChild,
hydrateInstance, hydrateInstance,
hydrateTextInstance, hydrateTextInstance,
getNextHydratableInstanceAfterSuspenseInstance,
didNotMatchHydratedContainerTextInstance, didNotMatchHydratedContainerTextInstance,
didNotMatchHydratedTextInstance, didNotMatchHydratedTextInstance,
didNotHydrateContainerInstance, didNotHydrateContainerInstance,
didNotHydrateInstance, didNotHydrateInstance,
didNotFindHydratableContainerInstance, didNotFindHydratableContainerInstance,
didNotFindHydratableContainerTextInstance, didNotFindHydratableContainerTextInstance,
didNotFindHydratableContainerSuspenseInstance,
didNotFindHydratableInstance, didNotFindHydratableInstance,
didNotFindHydratableTextInstance, didNotFindHydratableTextInstance,
didNotFindHydratableSuspenseInstance,
} from './ReactFiberHostConfig'; } from './ReactFiberHostConfig';
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
// The deepest Fiber on the stack involved in a hydration context. // The deepest Fiber on the stack involved in a hydration context.
// This may have been an insertion or a hydration. // This may have been an insertion or a hydration.
@ -58,6 +70,20 @@ function enterHydrationState(fiber: Fiber): boolean {
return true; return true;
} }
function reenterHydrationStateFromDehydratedSuspenseInstance(
fiber: Fiber,
): boolean {
if (!supportsHydration) {
return false;
}
const suspenseInstance = fiber.stateNode;
nextHydratableInstance = getNextHydratableSibling(suspenseInstance);
popToNextHostParent(fiber);
isHydrating = true;
return true;
}
function deleteHydratableInstance( function deleteHydratableInstance(
returnFiber: Fiber, returnFiber: Fiber,
instance: HydratableInstance, instance: HydratableInstance,
@ -115,6 +141,9 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
const text = fiber.pendingProps; const text = fiber.pendingProps;
didNotFindHydratableContainerTextInstance(parentContainer, text); didNotFindHydratableContainerTextInstance(parentContainer, text);
break; break;
case SuspenseComponent:
didNotFindHydratableContainerSuspenseInstance(parentContainer);
break;
} }
break; break;
} }
@ -143,6 +172,13 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
text, text,
); );
break; break;
case SuspenseComponent:
didNotFindHydratableSuspenseInstance(
parentType,
parentProps,
parentInstance,
);
break;
} }
break; break;
} }
@ -173,6 +209,18 @@ function tryHydrate(fiber, nextInstance) {
} }
return false; return false;
} }
case SuspenseComponent: {
if (enableSuspenseServerRenderer) {
const suspenseInstance = canHydrateSuspenseInstance(nextInstance);
if (suspenseInstance !== null) {
// Downgrade the tag to a dehydrated component until we've hydrated it.
fiber.tag = DehydratedSuspenseComponent;
fiber.stateNode = (suspenseInstance: SuspenseInstance);
return true;
}
}
return false;
}
default: default:
return false; return false;
} }
@ -296,12 +344,32 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
return shouldUpdate; return shouldUpdate;
} }
function skipPastDehydratedSuspenseInstance(fiber: Fiber): void {
if (!supportsHydration) {
invariant(
false,
'Expected skipPastDehydratedSuspenseInstance() to never be called. ' +
'This error is likely caused by a bug in React. Please file an issue.',
);
}
let suspenseInstance = fiber.stateNode;
invariant(
suspenseInstance,
'Expected to have a hydrated suspense instance. ' +
'This error is likely caused by a bug in React. Please file an issue.',
);
nextHydratableInstance = getNextHydratableInstanceAfterSuspenseInstance(
suspenseInstance,
);
}
function popToNextHostParent(fiber: Fiber): void { function popToNextHostParent(fiber: Fiber): void {
let parent = fiber.return; let parent = fiber.return;
while ( while (
parent !== null && parent !== null &&
parent.tag !== HostComponent && parent.tag !== HostComponent &&
parent.tag !== HostRoot parent.tag !== HostRoot &&
parent.tag !== DehydratedSuspenseComponent
) { ) {
parent = parent.return; parent = parent.return;
} }
@ -365,9 +433,11 @@ function resetHydrationState(): void {
export { export {
enterHydrationState, enterHydrationState,
reenterHydrationStateFromDehydratedSuspenseInstance,
resetHydrationState, resetHydrationState,
tryToClaimNextHydratableInstance, tryToClaimNextHydratableInstance,
prepareToHydrateHostInstance, prepareToHydrateHostInstance,
prepareToHydrateHostTextInstance, prepareToHydrateHostTextInstance,
skipPastDehydratedSuspenseInstance,
popHydrationState, popHydrationState,
}; };

View File

@ -27,7 +27,11 @@ import warningWithoutStack from 'shared/warningWithoutStack';
import {isPrimaryRenderer} from './ReactFiberHostConfig'; import {isPrimaryRenderer} from './ReactFiberHostConfig';
import {createCursor, push, pop} from './ReactFiberStack'; import {createCursor, push, pop} from './ReactFiberStack';
import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt';
import {ContextProvider, ClassComponent} from 'shared/ReactWorkTags'; import {
ContextProvider,
ClassComponent,
DehydratedSuspenseComponent,
} from 'shared/ReactWorkTags';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
import warning from 'shared/warning'; import warning from 'shared/warning';
@ -39,6 +43,7 @@ import {
} from 'react-reconciler/src/ReactUpdateQueue'; } from 'react-reconciler/src/ReactUpdateQueue';
import {NoWork} from './ReactFiberExpirationTime'; import {NoWork} from './ReactFiberExpirationTime';
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
const valueCursor: StackCursor<mixed> = createCursor(null); const valueCursor: StackCursor<mixed> = createCursor(null);
@ -150,6 +155,37 @@ export function calculateChangedBits<T>(
} }
} }
function scheduleWorkOnParentPath(
parent: Fiber | null,
renderExpirationTime: ExpirationTime,
) {
// Update the child expiration time of all the ancestors, including
// the alternates.
let node = parent;
while (node !== null) {
let alternate = node.alternate;
if (node.childExpirationTime < renderExpirationTime) {
node.childExpirationTime = renderExpirationTime;
if (
alternate !== null &&
alternate.childExpirationTime < renderExpirationTime
) {
alternate.childExpirationTime = renderExpirationTime;
}
} else if (
alternate !== null &&
alternate.childExpirationTime < renderExpirationTime
) {
alternate.childExpirationTime = renderExpirationTime;
} else {
// Neither alternate was updated, which means the rest of the
// ancestor path already has sufficient priority.
break;
}
node = node.return;
}
}
export function propagateContextChange( export function propagateContextChange(
workInProgress: Fiber, workInProgress: Fiber,
context: ReactContext<mixed>, context: ReactContext<mixed>,
@ -199,31 +235,8 @@ export function propagateContextChange(
) { ) {
alternate.expirationTime = renderExpirationTime; alternate.expirationTime = renderExpirationTime;
} }
// Update the child expiration time of all the ancestors, including
// the alternates. scheduleWorkOnParentPath(fiber.return, renderExpirationTime);
let node = fiber.return;
while (node !== null) {
alternate = node.alternate;
if (node.childExpirationTime < renderExpirationTime) {
node.childExpirationTime = renderExpirationTime;
if (
alternate !== null &&
alternate.childExpirationTime < renderExpirationTime
) {
alternate.childExpirationTime = renderExpirationTime;
}
} else if (
alternate !== null &&
alternate.childExpirationTime < renderExpirationTime
) {
alternate.childExpirationTime = renderExpirationTime;
} else {
// Neither alternate was updated, which means the rest of the
// ancestor path already has sufficient priority.
break;
}
node = node.return;
}
// Mark the expiration time on the list, too. // Mark the expiration time on the list, too.
if (list.expirationTime < renderExpirationTime) { if (list.expirationTime < renderExpirationTime) {
@ -239,6 +252,29 @@ export function propagateContextChange(
} else if (fiber.tag === ContextProvider) { } else if (fiber.tag === ContextProvider) {
// Don't scan deeper if this is a matching provider // Don't scan deeper if this is a matching provider
nextFiber = fiber.type === workInProgress.type ? null : fiber.child; nextFiber = fiber.type === workInProgress.type ? null : fiber.child;
} else if (
enableSuspenseServerRenderer &&
fiber.tag === DehydratedSuspenseComponent
) {
// If a dehydrated suspense component is in this subtree, we don't know
// if it will have any context consumers in it. The best we can do is
// mark it as having updates on its children.
if (fiber.expirationTime < renderExpirationTime) {
fiber.expirationTime = renderExpirationTime;
}
let alternate = fiber.alternate;
if (
alternate !== null &&
alternate.expirationTime < renderExpirationTime
) {
alternate.expirationTime = renderExpirationTime;
}
// This is intentionally passing this fiber as the parent
// because we want to schedule this fiber as having work
// on its children. We'll use the childExpirationTime on
// this fiber to indicate that a context has changed.
scheduleWorkOnParentPath(fiber, renderExpirationTime);
nextFiber = fiber.sibling;
} else { } else {
// Traverse down. // Traverse down.
nextFiber = fiber.child; nextFiber = fiber.child;

View File

@ -60,6 +60,8 @@ import {
HostRoot, HostRoot,
MemoComponent, MemoComponent,
SimpleMemoComponent, SimpleMemoComponent,
SuspenseComponent,
DehydratedSuspenseComponent,
} from 'shared/ReactWorkTags'; } from 'shared/ReactWorkTags';
import { import {
enableSchedulerTracing, enableSchedulerTracing,
@ -67,6 +69,7 @@ import {
enableUserTimingAPI, enableUserTimingAPI,
replayFailedUnitOfWorkWithInvokeGuardedCallback, replayFailedUnitOfWorkWithInvokeGuardedCallback,
warnAboutDeprecatedLifecycles, warnAboutDeprecatedLifecycles,
enableSuspenseServerRenderer,
} from 'shared/ReactFeatureFlags'; } from 'shared/ReactFeatureFlags';
import getComponentName from 'shared/getComponentName'; import getComponentName from 'shared/getComponentName';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
@ -1697,8 +1700,25 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) {
// resolved, which means at least part of the tree was likely unblocked. Try // resolved, which means at least part of the tree was likely unblocked. Try
// rendering again, at a new expiration time. // rendering again, at a new expiration time.
const retryCache: WeakSet<Thenable> | Set<Thenable> | null = let retryCache: WeakSet<Thenable> | Set<Thenable> | null;
boundaryFiber.stateNode; if (enableSuspenseServerRenderer) {
switch (boundaryFiber.tag) {
case SuspenseComponent:
retryCache = boundaryFiber.stateNode;
break;
case DehydratedSuspenseComponent:
retryCache = boundaryFiber.memoizedState;
break;
default:
invariant(
false,
'Pinged unknown suspense boundary type. ' +
'This is probably a bug in React.',
);
}
} else {
retryCache = boundaryFiber.stateNode;
}
if (retryCache !== null) { if (retryCache !== null) {
// The thenable resolved, so we no longer need to memoize, because it will // The thenable resolved, so we no longer need to memoize, because it will
// never be thrown again. // never be thrown again.

View File

@ -25,6 +25,7 @@ import {
HostPortal, HostPortal,
ContextProvider, ContextProvider,
SuspenseComponent, SuspenseComponent,
DehydratedSuspenseComponent,
IncompleteClassComponent, IncompleteClassComponent,
} from 'shared/ReactWorkTags'; } from 'shared/ReactWorkTags';
import { import {
@ -34,7 +35,10 @@ import {
ShouldCapture, ShouldCapture,
LifecycleEffectMask, LifecycleEffectMask,
} from 'shared/ReactSideEffectTags'; } from 'shared/ReactSideEffectTags';
import {enableSchedulerTracing} from 'shared/ReactFeatureFlags'; import {
enableSchedulerTracing,
enableSuspenseServerRenderer,
} from 'shared/ReactFeatureFlags';
import {ConcurrentMode} from './ReactTypeOfMode'; import {ConcurrentMode} from './ReactTypeOfMode';
import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent'; import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent';
@ -62,6 +66,7 @@ import {
markLegacyErrorBoundaryAsFailed, markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary, isAlreadyFailedLegacyErrorBoundary,
pingSuspendedRoot, pingSuspendedRoot,
retryTimedOutBoundary,
} from './ReactFiberScheduler'; } from './ReactFiberScheduler';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
@ -73,6 +78,7 @@ import {
} from './ReactFiberExpirationTime'; } from './ReactFiberExpirationTime';
import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority'; import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority';
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
function createRootErrorUpdate( function createRootErrorUpdate(
@ -144,6 +150,43 @@ function createClassErrorUpdate(
return update; return update;
} }
function attachPingListener(
root: FiberRoot,
renderExpirationTime: ExpirationTime,
thenable: Thenable,
) {
// Attach a listener to the promise to "ping" the root and retry. But
// only if one does not already exist for the current render expiration
// time (which acts like a "thread ID" here).
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(thenable, threadIDs);
} else {
threadIDs = pingCache.get(thenable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(thenable, threadIDs);
}
}
if (!threadIDs.has(renderExpirationTime)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(renderExpirationTime);
let ping = pingSuspendedRoot.bind(
null,
root,
thenable,
renderExpirationTime,
);
if (enableSchedulerTracing) {
ping = Schedule_tracing_wrap(ping);
}
thenable.then(ping, ping);
}
}
function throwException( function throwException(
root: FiberRoot, root: FiberRoot,
returnFiber: Fiber, returnFiber: Fiber,
@ -198,6 +241,9 @@ function throwException(
} }
} }
} }
// If there is a DehydratedSuspenseComponent we don't have to do anything because
// if something suspends inside it, we will simply leave that as dehydrated. It
// will never timeout.
workInProgress = workInProgress.return; workInProgress = workInProgress.return;
} while (workInProgress !== null); } while (workInProgress !== null);
@ -265,36 +311,7 @@ function throwException(
// Confirmed that the boundary is in a concurrent mode tree. Continue // Confirmed that the boundary is in a concurrent mode tree. Continue
// with the normal suspend path. // with the normal suspend path.
// Attach a listener to the promise to "ping" the root and retry. But attachPingListener(root, renderExpirationTime, thenable);
// only if one does not already exist for the current render expiration
// time (which acts like a "thread ID" here).
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(thenable, threadIDs);
} else {
threadIDs = pingCache.get(thenable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(thenable, threadIDs);
}
}
if (!threadIDs.has(renderExpirationTime)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(renderExpirationTime);
let ping = pingSuspendedRoot.bind(
null,
root,
thenable,
renderExpirationTime,
);
if (enableSchedulerTracing) {
ping = Schedule_tracing_wrap(ping);
}
thenable.then(ping, ping);
}
let absoluteTimeoutMs; let absoluteTimeoutMs;
if (earliestTimeoutMs === -1) { if (earliestTimeoutMs === -1) {
@ -331,6 +348,40 @@ function throwException(
// whole tree. // whole tree.
renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime); renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
workInProgress.effectTag |= ShouldCapture;
workInProgress.expirationTime = renderExpirationTime;
return;
} else if (
enableSuspenseServerRenderer &&
workInProgress.tag === DehydratedSuspenseComponent
) {
attachPingListener(root, renderExpirationTime, thenable);
// Since we already have a current fiber, we can eagerly add a retry listener.
let retryCache = workInProgress.memoizedState;
if (retryCache === null) {
retryCache = workInProgress.memoizedState = new PossiblyWeakSet();
const current = workInProgress.alternate;
invariant(
current,
'A dehydrated suspense boundary must commit before trying to render. ' +
'This is probably a bug in React.',
);
current.memoizedState = retryCache;
}
// Memoize using the boundary fiber to prevent redundant listeners.
if (!retryCache.has(thenable)) {
retryCache.add(thenable);
let retry = retryTimedOutBoundary.bind(
null,
workInProgress,
thenable,
);
if (enableSchedulerTracing) {
retry = Schedule_tracing_wrap(retry);
}
thenable.then(retry, retry);
}
workInProgress.effectTag |= ShouldCapture; workInProgress.effectTag |= ShouldCapture;
workInProgress.expirationTime = renderExpirationTime; workInProgress.expirationTime = renderExpirationTime;
return; return;
@ -432,6 +483,7 @@ function unwindWork(
return workInProgress; return workInProgress;
} }
case HostComponent: { case HostComponent: {
// TODO: popHydrationState
popHostContext(workInProgress); popHostContext(workInProgress);
return null; return null;
} }
@ -444,6 +496,18 @@ function unwindWork(
} }
return null; return null;
} }
case DehydratedSuspenseComponent: {
if (enableSuspenseServerRenderer) {
// TODO: popHydrationState
const effectTag = workInProgress.effectTag;
if (effectTag & ShouldCapture) {
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
// Captured a suspense effect. Re-render the boundary.
return workInProgress;
}
}
return null;
}
case HostPortal: case HostPortal:
popHostContainer(workInProgress); popHostContainer(workInProgress);
return null; return null;

View File

@ -29,6 +29,7 @@ export opaque type Props = mixed; // eslint-disable-line no-undef
export opaque type Container = mixed; // eslint-disable-line no-undef export opaque type Container = mixed; // eslint-disable-line no-undef
export opaque type Instance = mixed; // eslint-disable-line no-undef export opaque type Instance = mixed; // eslint-disable-line no-undef
export opaque type TextInstance = mixed; // eslint-disable-line no-undef export opaque type TextInstance = mixed; // eslint-disable-line no-undef
export opaque type SuspenseInstance = mixed; // eslint-disable-line no-undef
export opaque type HydratableInstance = mixed; // eslint-disable-line no-undef export opaque type HydratableInstance = mixed; // eslint-disable-line no-undef
export opaque type PublicInstance = mixed; // eslint-disable-line no-undef export opaque type PublicInstance = mixed; // eslint-disable-line no-undef
export opaque type HostContext = mixed; // eslint-disable-line no-undef export opaque type HostContext = mixed; // eslint-disable-line no-undef
@ -104,10 +105,17 @@ export const createHiddenTextInstance = $$$hostConfig.createHiddenTextInstance;
// ------------------- // -------------------
export const canHydrateInstance = $$$hostConfig.canHydrateInstance; export const canHydrateInstance = $$$hostConfig.canHydrateInstance;
export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance;
export const canHydrateSuspenseInstance =
$$$hostConfig.canHydrateSuspenseInstance;
export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild;
export const hydrateInstance = $$$hostConfig.hydrateInstance; export const hydrateInstance = $$$hostConfig.hydrateInstance;
export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance;
export const getNextHydratableInstanceAfterSuspenseInstance =
$$$hostConfig.getNextHydratableInstanceAfterSuspenseInstance;
export const clearSuspenseBoundary = $$$hostConfig.clearSuspenseBoundary;
export const clearSuspenseBoundaryFromContainer =
$$$hostConfig.clearSuspenseBoundaryFromContainer;
export const didNotMatchHydratedContainerTextInstance = export const didNotMatchHydratedContainerTextInstance =
$$$hostConfig.didNotMatchHydratedContainerTextInstance; $$$hostConfig.didNotMatchHydratedContainerTextInstance;
export const didNotMatchHydratedTextInstance = export const didNotMatchHydratedTextInstance =
@ -119,7 +127,11 @@ export const didNotFindHydratableContainerInstance =
$$$hostConfig.didNotFindHydratableContainerInstance; $$$hostConfig.didNotFindHydratableContainerInstance;
export const didNotFindHydratableContainerTextInstance = export const didNotFindHydratableContainerTextInstance =
$$$hostConfig.didNotFindHydratableContainerTextInstance; $$$hostConfig.didNotFindHydratableContainerTextInstance;
export const didNotFindHydratableContainerSuspenseInstance =
$$$hostConfig.didNotFindHydratableContainerSuspenseInstance;
export const didNotFindHydratableInstance = export const didNotFindHydratableInstance =
$$$hostConfig.didNotFindHydratableInstance; $$$hostConfig.didNotFindHydratableInstance;
export const didNotFindHydratableTextInstance = export const didNotFindHydratableTextInstance =
$$$hostConfig.didNotFindHydratableTextInstance; $$$hostConfig.didNotFindHydratableTextInstance;
export const didNotFindHydratableSuspenseInstance =
$$$hostConfig.didNotFindHydratableSuspenseInstance;

View File

@ -22,18 +22,25 @@ function shim(...args: any) {
} }
// Hydration (when unsupported) // Hydration (when unsupported)
export type SuspenseInstance = mixed;
export const supportsHydration = false; export const supportsHydration = false;
export const canHydrateInstance = shim; export const canHydrateInstance = shim;
export const canHydrateTextInstance = shim; export const canHydrateTextInstance = shim;
export const canHydrateSuspenseInstance = shim;
export const getNextHydratableSibling = shim; export const getNextHydratableSibling = shim;
export const getFirstHydratableChild = shim; export const getFirstHydratableChild = shim;
export const hydrateInstance = shim; export const hydrateInstance = shim;
export const hydrateTextInstance = shim; export const hydrateTextInstance = shim;
export const getNextHydratableInstanceAfterSuspenseInstance = shim;
export const clearSuspenseBoundary = shim;
export const clearSuspenseBoundaryFromContainer = shim;
export const didNotMatchHydratedContainerTextInstance = shim; export const didNotMatchHydratedContainerTextInstance = shim;
export const didNotMatchHydratedTextInstance = shim; export const didNotMatchHydratedTextInstance = shim;
export const didNotHydrateContainerInstance = shim; export const didNotHydrateContainerInstance = shim;
export const didNotHydrateInstance = shim; export const didNotHydrateInstance = shim;
export const didNotFindHydratableContainerInstance = shim; export const didNotFindHydratableContainerInstance = shim;
export const didNotFindHydratableContainerTextInstance = shim; export const didNotFindHydratableContainerTextInstance = shim;
export const didNotFindHydratableContainerSuspenseInstance = shim;
export const didNotFindHydratableInstance = shim; export const didNotFindHydratableInstance = shim;
export const didNotFindHydratableTextInstance = shim; export const didNotFindHydratableTextInstance = shim;
export const didNotFindHydratableSuspenseInstance = shim;

View File

@ -46,3 +46,4 @@ export const MemoComponent = 14;
export const SimpleMemoComponent = 15; export const SimpleMemoComponent = 15;
export const LazyComponent = 16; export const LazyComponent = 16;
export const IncompleteClassComponent = 17; export const IncompleteClassComponent = 17;
export const DehydratedSuspenseComponent = 18;