diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 8e3c99ccc283..86674c72e4e8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -714,7 +714,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hi'); }); - it('shows the fallback of the outer if fallback is missing', async () => { + it('treats missing fallback the same as if it was defined', 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; @@ -759,7 +759,8 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - expect(ref.current).toBe(null); + const span = container.getElementsByTagName('span')[0]; + expect(ref.current).toBe(span); // Render an update, but leave it still suspended. root.render(); @@ -768,9 +769,9 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - expect(container.getElementsByTagName('span').length).toBe(0); - expect(ref.current).toBe(null); - expect(container.textContent).toBe('Loading...'); + expect(container.getElementsByTagName('span').length).toBe(1); + expect(ref.current).toBe(span); + expect(container.textContent).toBe(''); // Unsuspending shows the content. suspend = false; @@ -780,7 +781,6 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - const span = container.getElementsByTagName('span')[0]; expect(span.textContent).toBe('Hi'); expect(span.className).toBe('hi'); expect(ref.current).toBe(span); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index 12fc6987cf59..6e2ec3150d7f 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -471,10 +471,12 @@ describe('ReactDOMServerHydration', () => { element, ); - // Because this didn't have a fallback, it was hydrated as if it's - // not a Suspense boundary. - expect(ref.current).toBe(div); - expect(element.innerHTML).toBe('
Hello World
'); + // The content should've been client rendered. + expect(ref.current).not.toBe(div); + // Unfortunately, since we don't delete the tail at the root, a duplicate will remain. + expect(element.innerHTML).toBe( + '
Hello World
Hello World
', + ); }); // regression test for https://github.com/facebook/react/issues/17170 diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 752af3b2c737..455309db79c7 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -1123,25 +1123,6 @@ class ReactDOMServerRenderer { case REACT_SUSPENSE_TYPE: { if (enableSuspenseServerRenderer) { const 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( ((nextChild: any): ReactElement).props.children, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 581bf6c76d45..50a5a81a1f09 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { // This is a new mount or this boundary is already showing a fallback state. // Mark this subtree context as having at least one invisible parent that could // handle the fallback state. - // Boundaries without fallbacks or should be avoided are not considered since - // they cannot handle preferred fallback states. - if ( - nextProps.fallback !== undefined && - nextProps.unstable_avoidThisFallback !== true - ) { + // Avoided boundaries are not considered since they cannot handle preferred fallback states. + if (nextProps.unstable_avoidThisFallback !== true) { suspenseContext = addSubtreeSuspenseContext( suspenseContext, InvisibleParentSuspenseContext, @@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { if (current === null) { // Initial mount // 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 been a dehydrated suspense component. - if (enableSuspenseServerRenderer) { - const suspenseState: null | SuspenseState = - workInProgress.memoizedState; - if (suspenseState !== null) { - const dehydrated = suspenseState.dehydrated; - if (dehydrated !== null) { - return mountDehydratedSuspenseComponent( - workInProgress, - dehydrated, - renderLanes, - ); - } + tryToClaimNextHydratableInstance(workInProgress); + // This could've been a dehydrated suspense component. + if (enableSuspenseServerRenderer) { + const suspenseState: null | SuspenseState = workInProgress.memoizedState; + if (suspenseState !== null) { + const dehydrated = suspenseState.dehydrated; + if (dehydrated !== null) { + return mountDehydratedSuspenseComponent( + workInProgress, + dehydrated, + renderLanes, + ); } } } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index b4dde9e826d8..fa0a9840e758 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { // This is a new mount or this boundary is already showing a fallback state. // Mark this subtree context as having at least one invisible parent that could // handle the fallback state. - // Boundaries without fallbacks or should be avoided are not considered since - // they cannot handle preferred fallback states. - if ( - nextProps.fallback !== undefined && - nextProps.unstable_avoidThisFallback !== true - ) { + // Avoided boundaries are not considered since they cannot handle preferred fallback states. + if (nextProps.unstable_avoidThisFallback !== true) { suspenseContext = addSubtreeSuspenseContext( suspenseContext, InvisibleParentSuspenseContext, @@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { if (current === null) { // Initial mount // 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 been a dehydrated suspense component. - if (enableSuspenseServerRenderer) { - const suspenseState: null | SuspenseState = - workInProgress.memoizedState; - if (suspenseState !== null) { - const dehydrated = suspenseState.dehydrated; - if (dehydrated !== null) { - return mountDehydratedSuspenseComponent( - workInProgress, - dehydrated, - renderLanes, - ); - } + tryToClaimNextHydratableInstance(workInProgress); + // This could've been a dehydrated suspense component. + if (enableSuspenseServerRenderer) { + const suspenseState: null | SuspenseState = workInProgress.memoizedState; + if (suspenseState !== null) { + const dehydrated = suspenseState.dehydrated; + if (dehydrated !== null) { + return mountDehydratedSuspenseComponent( + workInProgress, + dehydrated, + renderLanes, + ); } } } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index e11e15bac8a6..29c5fe48c4b2 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -1045,9 +1045,7 @@ function completeWork( const nextDidTimeout = nextState !== null; let prevDidTimeout = false; if (current === null) { - if (workInProgress.memoizedProps.fallback !== undefined) { - popHydrationState(workInProgress); - } + popHydrationState(workInProgress); } else { const prevState: null | SuspenseState = current.memoizedState; prevDidTimeout = prevState !== null; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index ba6200d364a3..a119a6b97519 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -1045,9 +1045,7 @@ function completeWork( const nextDidTimeout = nextState !== null; let prevDidTimeout = false; if (current === null) { - if (workInProgress.memoizedProps.fallback !== undefined) { - popHydrationState(workInProgress); - } + popHydrationState(workInProgress); } else { const prevState: null | SuspenseState = current.memoizedState; prevDidTimeout = prevState !== null; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index b1077092d46a..5ad7ae650249 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -77,10 +77,6 @@ export function shouldCaptureSuspense( return false; } const props = workInProgress.memoizedProps; - // In order to capture, the Suspense component must have a fallback prop. - if (props.fallback === undefined) { - return false; - } // Regular boundaries always capture. if (props.unstable_avoidThisFallback !== true) { return true; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index b97064696760..51bef1df3a56 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -77,10 +77,6 @@ export function shouldCaptureSuspense( return false; } const props = workInProgress.memoizedProps; - // In order to capture, the Suspense component must have a fallback prop. - if (props.fallback === undefined) { - return false; - } // Regular boundaries always capture. if (props.unstable_avoidThisFallback !== true) { return true; diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index ce9236e114b9..dea74cb18ca1 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -19,8 +19,6 @@ let Scheduler; let ReactDOMServer; let act; -// Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to -// gradually migrate those to this file. describe('ReactHooks', () => { beforeEach(() => { jest.resetModules(); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index e7e167972404..32904ebd671e 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -9,8 +9,6 @@ let act; let TextResource; let textResourceShouldFail; -// Additional tests can be found in ReactSuspenseWithNoopRenderer. Plan is -// to gradually migrate those to this file. describe('ReactSuspense', () => { beforeEach(() => { jest.resetModules(); @@ -391,44 +389,10 @@ describe('ReactSuspense', () => { expect(root).toMatchRenderedOutput('Hi'); }); - it('only captures if `fallback` is defined', () => { - const root = ReactTestRenderer.create( - }> - - - - , - { - unstable_isConcurrent: true, - }, - ); - - expect(Scheduler).toFlushAndYield([ - 'Suspend! [Hi]', - // The outer fallback should be rendered, because the inner one does not - // have a `fallback` prop - 'Loading...', - ]); - jest.advanceTimersByTime(1000); - expect(Scheduler).toHaveYielded([]); - expect(Scheduler).toFlushAndYield([]); - expect(root).toMatchRenderedOutput('Loading...'); - - jest.advanceTimersByTime(5000); - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); - expect(Scheduler).toFlushAndYield(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); - }); - - it('throws if tree suspends and none of the Suspense ancestors have a fallback', () => { - ReactTestRenderer.create( - - - , - { - unstable_isConcurrent: true, - }, - ); + it('throws if tree suspends and none of the Suspense ancestors have a boundary', () => { + ReactTestRenderer.create(, { + unstable_isConcurrent: true, + }); expect(Scheduler).toFlushAndThrow( 'AsyncText suspended while rendering, but no fallback UI was specified.', diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseFallback-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseFallback-test.js new file mode 100644 index 000000000000..18e21a43c134 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseFallback-test.js @@ -0,0 +1,223 @@ +/** + * 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. + * + * @flow + */ +let React; +let ReactNoop; +let Scheduler; +let Suspense; +let getCacheForType; +let caches; +let seededCache; + +describe('ReactSuspenseFallback', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + Suspense = React.Suspense; + getCacheForType = React.unstable_getCacheForType; + caches = []; + seededCache = null; + }); + + function createTextCache() { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const version = caches.length + 1; + const cache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error! [${text}]`); + throw record.value; + case 'resolved': + return textCache.version; + } + } else { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return ; + } + + function AsyncText({text, showVersion}) { + const version = readText(text); + const fullText = showVersion ? `${text} [v${version}]` : text; + Scheduler.unstable_yieldValue(fullText); + return ; + } + + function span(prop) { + return {type: 'span', children: [], prop, hidden: false}; + } + + // @gate enableCache + it('suspends and shows fallback', () => { + ReactNoop.render( + }> + + , + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // @gate enableCache + it('suspends and shows null fallback', () => { + ReactNoop.render( + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + // null + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableCache + it('suspends and shows undefined fallback', () => { + ReactNoop.render( + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + // null + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableCache + it('suspends and shows inner fallback', () => { + ReactNoop.render( + }> + }> + + + , + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // @gate enableCache + it('suspends and shows inner undefined fallback', () => { + ReactNoop.render( + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + // null + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableCache + it('suspends and shows inner null fallback', () => { + ReactNoop.render( + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + // null + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index f50b3a6a7e44..8c6f9dadb6a2 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -769,6 +769,89 @@ describe('ReactSuspenseList', () => { ); }); + it('boundaries without fallbacks can be coordinate with SuspenseList', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo({showMore}) { + return ( + }> + + + + + {showMore ? ( + <> + + + + + + + + ) : null} + + + ); + } + + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + // null + ]); + + expect(ReactNoop).toMatchRenderedOutput(null); + + await A.resolve(); + + expect(Scheduler).toFlushAndYield(['A']); + + expect(ReactNoop).toMatchRenderedOutput(A); + + // Let's do an update that should consult the avoided boundaries. + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield([ + 'A', + 'Suspend! [B]', + // null + 'Suspend! [C]', + // null + 'A', + // null + // null + ]); + + // This will suspend, since the boundaries are avoided. Give them + // time to display their loading states. + jest.advanceTimersByTime(500); + + // A is already showing content so it doesn't turn into a fallback. + expect(ReactNoop).toMatchRenderedOutput(A); + + await B.resolve(); + + expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]']); + + // Even though we could now show B, we're still waiting on C. + expect(ReactNoop).toMatchRenderedOutput(A); + + await C.resolve(); + + expect(Scheduler).toFlushAndYield(['B', 'C']); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + , + ); + }); + it('displays each items in "forwards" order', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 4110772513b8..165c227a8443 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -2301,6 +2301,55 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]); }); + // @gate enableCache + it('does not show the parent fallback if the inner fallback is not defined', async () => { + function Foo({showC}) { + Scheduler.unstable_yieldValue('Foo'); + return ( + }> + + + {showC ? : null} + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Foo', + 'Suspend! [A]', + 'B', + // null + ]); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + + // Eventually we resolve and show the data. + await resolveText('A'); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + + // Update to show C + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Foo', + 'A', + 'Suspend! [C]', + // null + 'B', + ]); + // Flush to skip suspended time. + Scheduler.unstable_advanceTime(600); + await advanceTimers(600); + expect(ReactNoop.getChildren()).toEqual([hiddenSpan('A'), span('B')]); + + // Later we load the data. + await resolveText('C'); + expect(Scheduler).toFlushAndYield(['A', 'C']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]); + }); + // @gate enableCache it('favors showing the inner fallback for nested top level avoided fallback', async () => { function Foo({showB}) { @@ -2391,6 +2440,57 @@ describe('ReactSuspenseWithNoopRenderer', () => { } }); + // @gate enableCache + it('keeps showing an undefined fallback if it is already showing', async () => { + function Foo({showB}) { + Scheduler.unstable_yieldValue('Foo'); + return ( + }> + + + {showB ? ( + + + + ) : null} + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo', 'A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render(); + }); + } else { + ReactNoop.render(); + } + + expect(Scheduler).toFlushAndYield([ + 'Foo', + 'A', + 'Suspend! [B]', + // Null + ]); + // Still suspended. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Flush to skip suspended time. + Scheduler.unstable_advanceTime(600); + await advanceTimers(600); + + if (gate(flags => flags.enableSyncDefaultUpdates)) { + // Transitions never fall back. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + } else { + expect(ReactNoop.getChildren()).toEqual([span('A')]); + } + }); + // @gate enableCache it('commits a suspended idle pri render within a reasonable time', async () => { function Foo({renderContent}) {