Repository: facebook/react
Pull request: [Fizz] Convert emulated-Fizz PartialHydration tests to Fizz tests

When implementing PartialHydration, I simulated what Fizz would do in the future in the tests. This ports those to proper Fizz tests with hydration so that we don't rely on implementation details (which varies slightly) in the tests.

Notably I left some comments where Fizz doesn't do the right thing with SuspenseList yet.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -16,6 +16,7 @@ let React;
 let ReactDOM;
 let ReactDOMFizzServer;
 let Suspense;
+let SuspenseList;
 let PropTypes;
 let textCache;
 let document;
@@ -37,6 +38,7 @@ describe('ReactDOMFizzServer', () => {
     }
     Stream = require('stream');
     Suspense = React.Suspense;
+    SuspenseList = React.unstable_SuspenseList;
     PropTypes = require('prop-types');
 
     textCache = new Map();
@@ -476,6 +478,7 @@ describe('ReactDOMFizzServer', () => {
     });
 
     // We're still showing a fallback.
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
 
     // Attempt to hydrate the content.
     const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
@@ -507,6 +510,176 @@ describe('ReactDOMFizzServer', () => {
     expect(ref.current).toBe(h1);
   });
 
+  // @gate experimental
+  it('handles an error on the client if the server ends up erroring', async () => {
+    const ref = React.createRef();
+
+    class ErrorBoundary extends React.Component {
+      state = {error: null};
+      static getDerivedStateFromError(error) {
+        return {error};
+      }
+      render() {
+        if (this.state.error) {
+          return <b ref={ref}>{this.state.error.message}</b>;
+        }
+        return this.props.children;
+      }
+    }
+
+    function App() {
+      return (
+        <ErrorBoundary>
+          <div>
+            <Suspense fallback="Loading...">
+              <span ref={ref}>
+                <AsyncText text="This Errors" />
+              </span>
+            </Suspense>
+          </div>
+        </ErrorBoundary>
+      );
+    }
+
+    const loggedErrors = [];
+
+    // We originally suspend the boundary and start streaming the loading state.
+    await act(async () => {
+      const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
+        <App />,
+        writable,
+        {
+          onError(x) {
+            loggedErrors.push(x);
+          },
+        },
+      );
+      startWriting();
+    });
+
+    // We're still showing a fallback.
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+    expect(loggedErrors).toEqual([]);
+
+    // Attempt to hydrate the content.
+    const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+    root.render(<App />);
+    Scheduler.unstable_flushAll();
+
+    // We're still loading because we're waiting for the server to stream more content.
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+    const theError = new Error('Error Message');
+    await act(async () => {
+      rejectText('This Errors', theError);
+    });
+
+    expect(loggedErrors).toEqual([theError]);
+
+    // The server errored, but we still haven't hydrated. We don't know if the
+    // client will succeed yet, so we still show the loading state.
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+    expect(ref.current).toBe(null);
+
+    // Flush the hydration.
+    Scheduler.unstable_flushAll();
+
+    // Hydrating should've generated an error and replaced the suspense boundary.
+    expect(getVisibleChildren(container)).toEqual(<b>Error Message</b>);
+
+    const b = container.getElementsByTagName('b')[0];
+    expect(ref.current).toBe(b);
+  });
+
+  // @gate experimental
+  it('shows inserted items before pending in a SuspenseList as fallbacks while hydrating', async () => {
+    const ref = React.createRef();
+
+    // These are hoisted to avoid them from rerendering.
+    const a = (
+      <Suspense fallback="Loading A">
+        <span ref={ref}>
+          <AsyncText text="A" />
+        </span>
+      </Suspense>
+    );
+    const b = (
+      <Suspense fallback="Loading B">
+        <span>
+          <Text text="B" />
+        </span>
+      </Suspense>
+    );
+
+    function App({showMore}) {
+      return (
+        <SuspenseList revealOrder="forwards">
+          {a}
+          {b}
+          {showMore ? (
+            <Suspense fallback="Loading C">
+              <span>C</span>
+            </Suspense>
+          ) : null}
+        </SuspenseList>
+      );
+    }
+
+    // We originally suspend the boundary and start streaming the loading state.
+    await act(async () => {
+      const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
+        <App showMore={false} />,
+        writable,
+      );
+      startWriting();
+    });
+
+    const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+    root.render(<App showMore={false} />);
+    Scheduler.unstable_flushAll();
+
+    // We're not hydrated yet.
+    expect(ref.current).toBe(null);
+    expect(getVisibleChildren(container)).toEqual([
+      'Loading A',
+      // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
+      // isn't implemented fully yet.
+      <span>B</span>,
+    ]);
+
+    // Add more rows before we've hydrated the first two.
+    root.render(<App showMore={true} />);
+    Scheduler.unstable_flushAll();
+
+    // We're not hydrated yet.
+    expect(ref.current).toBe(null);
+
+    // We haven't resolved yet.
+    expect(getVisibleChildren(container)).toEqual([
+      'Loading A',
+      // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
+      // isn't implemented fully yet.
+      <span>B</span>,
+      'Loading C',
+    ]);
+
+    await act(async () => {
+      await resolveText('A');
+    });
+
+    Scheduler.unstable_flushAll();
+
+    expect(getVisibleChildren(container)).toEqual([
+      <span>A</span>,
+      <span>B</span>,
+      <span>C</span>,
+    ]);
+
+    const span = container.getElementsByTagName('span')[0];
+    expect(ref.current).toBe(span);
+  });
+
   // @gate experimental
   it('client renders a boundary if it does not resolve before aborting', async () => {
     function App() {

filename: packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
patch: @@ -1358,189 +1358,6 @@ describe('ReactDOMServerPartialHydration', () => {
     expect(ref.current).toBe(span);
   });
 
-  // @gate experimental
-  it('waits for pending content to come in from the server and then hydrates it', async () => {
-    let suspend = false;
-    const promise = new Promise(resolvePromise => {});
-    const 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>
-      );
-    }
-
-    // We're going to simulate what Fizz will do during streaming rendering.
-
-    // First we generate the HTML of the loading state.
-    suspend = true;
-    const loadingHTML = ReactDOMServer.renderToString(<App />);
-    // Then we generate the HTML of the final content.
-    suspend = false;
-    const finalHTML = ReactDOMServer.renderToString(<App />);
-
-    const container = document.createElement('div');
-    container.innerHTML = loadingHTML;
-
-    const suspenseNode = container.firstChild.firstChild;
-    expect(suspenseNode.nodeType).toBe(8);
-    // Put the suspense node in hydration state.
-    suspenseNode.data = '$?';
-
-    // This will simulates new content streaming into the document and
-    // replacing the fallback with final content.
-    function streamInContent() {
-      const temp = document.createElement('div');
-      temp.innerHTML = finalHTML;
-      const finalSuspenseNode = temp.firstChild.firstChild;
-      const fallbackContent = suspenseNode.nextSibling;
-      const finalContent = finalSuspenseNode.nextSibling;
-      suspenseNode.parentNode.replaceChild(finalContent, fallbackContent);
-      suspenseNode.data = '$';
-      if (suspenseNode._reactRetry) {
-        suspenseNode._reactRetry();
-      }
-    }
-
-    // We're still showing a fallback.
-    expect(container.getElementsByTagName('span').length).toBe(0);
-
-    // Attempt to hydrate the content.
-    suspend = false;
-    const root = ReactDOM.createRoot(container, {hydrate: true});
-    root.render(<App />);
-    Scheduler.unstable_flushAll();
-    jest.runAllTimers();
-
-    // We're still loading because we're waiting for the server to stream more content.
-    expect(container.textContent).toBe('Loading...');
-
-    // The server now updates the content in place in the fallback.
-    streamInContent();
-
-    // The final HTML is now in place.
-    expect(container.textContent).toBe('Hello');
-    const span = container.getElementsByTagName('span')[0];
-
-    // But it is not yet hydrated.
-    expect(ref.current).toBe(null);
-
-    Scheduler.unstable_flushAll();
-    jest.runAllTimers();
-
-    // Now it's hydrated.
-    expect(ref.current).toBe(span);
-  });
-
-  // @gate experimental
-  it('handles an error on the client if the server ends up erroring', async () => {
-    let suspend = false;
-    const promise = new Promise(resolvePromise => {});
-    const ref = React.createRef();
-
-    function Child() {
-      if (suspend) {
-        throw promise;
-      } else {
-        throw new Error('Error Message');
-      }
-    }
-
-    class ErrorBoundary extends React.Component {
-      state = {error: null};
-      static getDerivedStateFromError(error) {
-        return {error};
-      }
-      render() {
-        if (this.state.error) {
-          return <div ref={ref}>{this.state.error.message}</div>;
-        }
-        return this.props.children;
-      }
-    }
-
-    function App() {
-      return (
-        <ErrorBoundary>
-          <div>
-            <Suspense fallback="Loading...">
-              <span ref={ref}>
-                <Child />
-              </span>
-            </Suspense>
-          </div>
-        </ErrorBoundary>
-      );
-    }
-
-    // We're going to simulate what Fizz will do during streaming rendering.
-
-    // First we generate the HTML of the loading state.
-    suspend = true;
-    const loadingHTML = ReactDOMServer.renderToString(<App />);
-
-    const container = document.createElement('div');
-    container.innerHTML = loadingHTML;
-
-    const suspenseNode = container.firstChild.firstChild;
-    expect(suspenseNode.nodeType).toBe(8);
-    // Put the suspense node in hydration state.
-    suspenseNode.data = '$?';
-
-    // This will simulates the server erroring and putting the fallback
-    // as the final state.
-    function streamInError() {
-      suspenseNode.data = '$!';
-      if (suspenseNode._reactRetry) {
-        suspenseNode._reactRetry();
-      }
-    }
-
-    // We're still showing a fallback.
-    expect(container.getElementsByTagName('span').length).toBe(0);
-
-    // Attempt to hydrate the content.
-    suspend = false;
-    const root = ReactDOM.createRoot(container, {hydrate: true});
-    root.render(<App />);
-    Scheduler.unstable_flushAll();
-    jest.runAllTimers();
-
-    // We're still loading because we're waiting for the server to stream more content.
-    expect(container.textContent).toBe('Loading...');
-
-    // The server now updates the content in place in the fallback.
-    streamInError();
-
-    // The server errored, but we still haven't hydrated. We don't know if the
-    // client will succeed yet, so we still show the loading state.
-    expect(container.textContent).toBe('Loading...');
-    expect(ref.current).toBe(null);
-
-    Scheduler.unstable_flushAll();
-    jest.runAllTimers();
-
-    // Hydrating should've generated an error and replaced the suspense boundary.
-    expect(container.textContent).toBe('Error Message');
-
-    const div = container.getElementsByTagName('div')[0];
-    expect(ref.current).toBe(div);
-  });
-
   // @gate experimental
   it('shows inserted items in a SuspenseList before content is hydrated', async () => {
     let suspend = false;
@@ -1702,98 +1519,6 @@ describe('ReactDOMServerPartialHydration', () => {
     expect(container.textContent).toBe('ALoading B');
   });
 
-  // @gate experimental
-  it('shows inserted items before pending in a SuspenseList as fallbacks', async () => {
-    let suspend = false;
-    let resolve;
-    const promise = new Promise(resolvePromise => (resolve = resolvePromise));
-    const ref = React.createRef();
-
-    function Child({children}) {
-      if (suspend) {
-        throw promise;
-      } else {
-        return children;
-      }
-    }
-
-    // These are hoisted to avoid them from rerendering.
-    const a = (
-      <Suspense fallback="Loading A">
-        <Child>
-          <span>A</span>
-        </Child>
-      </Suspense>
-    );
-    const b = (
-      <Suspense fallback="Loading B">
-        <Child>
-          <span ref={ref}>B</span>
-        </Child>
-      </Suspense>
-    );
-
-    function App({showMore}) {
-      return (
-        <SuspenseList revealOrder="forwards">
-          {a}
-          {b}
-          {showMore ? (
-            <Suspense fallback="Loading C">
-              <span>C</span>
-            </Suspense>
-          ) : null}
-        </SuspenseList>
-      );
-    }
-
-    suspend = false;
-    const html = ReactDOMServer.renderToString(<App showMore={false} />);
-
-    const container = document.createElement('div');
-    container.innerHTML = html;
-
-    const suspenseNode = container.firstChild;
-    expect(suspenseNode.nodeType).toBe(8);
-    // Put the suspense node in pending state.
-    suspenseNode.data = '$?';
-
-    const root = ReactDOM.createRoot(container, {hydrate: true});
-
-    suspend = true;
-    act(() => {
-      root.render(<App showMore={false} />);
-    });
-
-    // We're not hydrated yet.
-    expect(ref.current).toBe(null);
-    expect(container.textContent).toBe('AB');
-
-    // Add more rows before we've hydrated the first two.
-    act(() => {
-      root.render(<App showMore={true} />);
-    });
-
-    // We're not hydrated yet.
-    expect(ref.current).toBe(null);
-
-    // Since the first two are already showing their final content
-    // we should be able to show the real content.
-    expect(container.textContent).toBe('ABLoading C');
-
-    suspend = false;
-    await act(async () => {
-      // Resolve the boundary to be in its resolved final state.
-      suspenseNode.data = '$';
-      if (suspenseNode._reactRetry) {
-        suspenseNode._reactRetry();
-      }
-      await resolve();
-    });
-
-    expect(container.textContent).toBe('ABC');
-  });
-
   // @gate experimental
   it('clears server boundaries when SuspenseList runs out of time hydrating', async () => {
     let suspend = false;