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 &&