From bde4066af52e3f0215ce8ed93f8df40f2f6f109c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 11 Aug 2019 12:46:27 -0700 Subject: [PATCH] Dehydrated suspense boundaries in suspense list If we get an insertion after a boundary, that has not yet been hydrated, we take our best guess at which state the HTML is showing. isSuspenseInstancePending means that we're still waiting for more server HTML before we can hydrate. This should mean that we're showing the fallback state. isSuspenseInstanceFallback means that we want to client render something. That most likely means that the server was unable to render and is displaying a fallback state in this slot. Adds tests to ensure that dehydrated components don't consider the force flag. --- ...DOMServerPartialHydration-test.internal.js | 252 ++++++++++++++++++ .../src/ReactFiberSuspenseComponent.js | 13 +- 2 files changed, 264 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 1271e091fe85..060decf0ffc6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -15,6 +15,7 @@ let ReactDOMServer; let Scheduler; let ReactFeatureFlags; let Suspense; +let SuspenseList; let act; describe('ReactDOMServerPartialHydration', () => { @@ -30,6 +31,7 @@ describe('ReactDOMServerPartialHydration', () => { ReactDOMServer = require('react-dom/server'); Scheduler = require('scheduler'); Suspense = React.Suspense; + SuspenseList = React.unstable_SuspenseList; }); it('hydrates a parent even if a child Suspense boundary is blocked', async () => { @@ -1077,6 +1079,256 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(div); }); + it('shows inserted items in a SuspenseList before content is hydrated', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); + + function Child({children}) { + if (suspend) { + throw promise; + } else { + return children; + } + } + + // These are hoisted to avoid them from rerendering. + const a = ( + + + A + + + ); + const b = ( + + + B + + + ); + + function App({showMore}) { + return ( + + {a} + {b} + {showMore ? ( + + C + + ) : null} + + ); + } + + suspend = false; + let html = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = html; + + let spanB = container.getElementsByTagName('span')[1]; + + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + + suspend = true; + act(() => { + root.render(); + }); + + // 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(); + }); + + // 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('ABC'); + + suspend = false; + await act(async () => { + await resolve(); + }); + + expect(container.textContent).toBe('ABC'); + // We've hydrated the same span. + expect(ref.current).toBe(spanB); + }); + + it('shows is able to hydrate boundaries even if others in a list are pending', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); + + function Child({children}) { + if (suspend) { + throw promise; + } else { + return children; + } + } + + let promise2 = new Promise(() => {}); + function AlwaysSuspend() { + throw promise2; + } + + // This is hoisted to avoid them from rerendering. + const a = ( + + + A + + + ); + + function App({showMore}) { + return ( + + {a} + {showMore ? ( + + + + ) : null} + + ); + } + + suspend = false; + let html = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = html; + + let spanA = container.getElementsByTagName('span')[0]; + + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + + suspend = true; + act(() => { + root.render(); + }); + + // We're not hydrated yet. + expect(ref.current).toBe(null); + expect(container.textContent).toBe('A'); + + await act(async () => { + // Add another row before we've hydrated the first one. + root.render(); + // At the same time, we resolve the blocking promise. + suspend = false; + await resolve(); + }); + + // We should have been able to hydrate the first row. + expect(ref.current).toBe(spanA); + // Even though we're still slowing B. + expect(container.textContent).toBe('ALoading B'); + }); + + it('shows inserted items before pending in a SuspenseList as fallbacks', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); + + function Child({children}) { + if (suspend) { + throw promise; + } else { + return children; + } + } + + // These are hoisted to avoid them from rerendering. + const a = ( + + + A + + + ); + const b = ( + + + B + + + ); + + function App({showMore}) { + return ( + + {a} + {b} + {showMore ? ( + + C + + ) : null} + + ); + } + + suspend = false; + let html = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = html; + + let suspenseNode = container.firstChild; + expect(suspenseNode.nodeType).toBe(8); + // Put the suspense node in pending state. + suspenseNode.data = '$?'; + + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + + suspend = true; + act(() => { + root.render(); + }); + + // 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(); + }); + + // 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'); + }); + it('can client render nested boundaries', async () => { let suspend = false; let promise = new Promise(() => {}); diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 06f5aebc1b8d..ea0bf5fadd2d 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -11,6 +11,10 @@ import type {Fiber} from './ReactFiber'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {SuspenseComponent, SuspenseListComponent} from 'shared/ReactWorkTags'; import {NoEffect, DidCapture} from 'shared/ReactSideEffectTags'; +import { + isSuspenseInstancePending, + isSuspenseInstanceFallback, +} from './ReactFiberHostConfig'; // A null SuspenseState represents an unsuspended normal Suspense boundary. // A non-null SuspenseState means that it is blocked for one reason or another. @@ -83,7 +87,14 @@ export function findFirstSuspended(row: Fiber): null | Fiber { if (node.tag === SuspenseComponent) { const state: SuspenseState | null = node.memoizedState; if (state !== null) { - return node; + const dehydrated: null | SuspenseInstance = state.dehydrated; + if ( + dehydrated === null || + isSuspenseInstancePending(dehydrated) || + isSuspenseInstanceFallback(dehydrated) + ) { + return node; + } } } else if ( node.tag === SuspenseListComponent &&