diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index b76bae504dd85..3ba37693491a4 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -2514,6 +2514,215 @@ describe('ReactSuspenseWithNoopRenderer', () => { }); }); + describe('delays transitions when using React.startTranistion', () => { + // @gate experimental + it('top level render', async () => { + function App({page}) { + return ( + }> + + + ); + } + + // Initial render. + React.unstable_startTransition(() => ReactNoop.render()); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.unstable_advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + // Later we load the data. + Scheduler.unstable_advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + React.unstable_startTransition(() => ReactNoop.render()); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + Scheduler.unstable_advanceTime(2999); + await advanceTimers(2999); + // Since the timeout is infinite (or effectively infinite), + // we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Later we load the data. + Scheduler.unstable_advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + + // Start a long (infinite) transition. + React.unstable_startTransition(() => ReactNoop.render()); + expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']); + + // Advance past the current (effectively) infinite timeout. + // This is enforcing temporary behavior until it's truly infinite. + Scheduler.unstable_advanceTime(100000); + await advanceTimers(100000); + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('B'), + span('Loading...'), + ]); + }); + + // @gate experimental + it('hooks', async () => { + let transitionToPage; + function App() { + const [page, setPage] = React.useState('none'); + transitionToPage = setPage; + if (page === 'none') { + return null; + } + return ( + }> + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + + // Initial render. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('A')); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.unstable_advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // Later we load the data. + Scheduler.unstable_advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('B')); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + + Scheduler.unstable_advanceTime(2999); + await advanceTimers(2999); + // Since the timeout is infinite (or effectively infinite), + // we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + }); + + // Later we load the data. + Scheduler.unstable_advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + + // Start a long (infinite) transition. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('C')); + + expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']); + + // Advance past the current effectively infinite timeout. + // This is enforcing temporary behavior until it's truly infinite. + Scheduler.unstable_advanceTime(100000); + await advanceTimers(100000); + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('B'), + span('Loading...'), + ]); + }); + }); + + // @gate experimental + it('classes', async () => { + let transitionToPage; + class App extends React.Component { + state = {page: 'none'}; + render() { + transitionToPage = page => this.setState({page}); + const page = this.state.page; + if (page === 'none') { + return null; + } + return ( + }> + + + ); + } + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + + // Initial render. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('A')); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.unstable_advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // Later we load the data. + Scheduler.unstable_advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('B')); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + Scheduler.unstable_advanceTime(2999); + await advanceTimers(2999); + // Since the timeout is infinite (or effectively infinite), + // we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + }); + + // Later we load the data. + Scheduler.unstable_advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + + // Start a long (infinite) transition. + await ReactNoop.act(async () => { + React.unstable_startTransition(() => transitionToPage('C')); + + expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']); + + // Advance past the current effectively infinite timeout. + // This is enforcing temporary behavior until it's truly infinite. + Scheduler.unstable_advanceTime(100000); + await advanceTimers(100000); + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('B'), + span('Loading...'), + ]); + }); + }); + }); + // @gate experimental it('disables suspense config when nothing is passed to withSuspenseConfig', async () => { function App({page}) { diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index b06b59a10ab9a..50285328818b7 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -46,6 +46,8 @@ export { useTransition as unstable_useTransition, useDeferredValue, useDeferredValue as unstable_useDeferredValue, + startTransition, + startTransition as unstable_startTransition, SuspenseList, SuspenseList as unstable_SuspenseList, unstable_withSuspenseConfig, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index f5332574a88b4..ff00b13333da8 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -42,6 +42,7 @@ export { // exposeConcurrentModeAPIs useTransition as unstable_useTransition, useDeferredValue as unstable_useDeferredValue, + startTransition as unstable_startTransition, SuspenseList as unstable_SuspenseList, unstable_withSuspenseConfig, // enableBlocksAPI diff --git a/packages/react/index.js b/packages/react/index.js index a02725917e8bc..ebbb6cf42e6ba 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -72,6 +72,8 @@ export { createFactory, useTransition, useTransition as unstable_useTransition, + startTransition, + startTransition as unstable_startTransition, useDeferredValue, useDeferredValue as unstable_useDeferredValue, SuspenseList, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 916e6dcebe1f6..fb2e1dcbef4f2 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -45,6 +45,8 @@ export { useTransition as unstable_useTransition, useDeferredValue, useDeferredValue as unstable_useDeferredValue, + startTransition, + startTransition as unstable_startTransition, SuspenseList, SuspenseList as unstable_SuspenseList, unstable_withSuspenseConfig, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index db7cf977ff9c5..bd737472a5eb8 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -58,6 +58,7 @@ import { import {createMutableSource} from './ReactMutableSource'; import ReactSharedInternals from './ReactSharedInternals'; import {createFundamental} from './ReactFundamental'; +import {startTransition} from './ReactStartTransition'; // TODO: Move this branching into the other module instead and just re-export. const createElement = __DEV__ ? createElementWithValidation : createElementProd; @@ -107,6 +108,7 @@ export { createFactory, // Concurrent Mode useTransition, + startTransition, useDeferredValue, REACT_SUSPENSE_LIST_TYPE as SuspenseList, REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden, diff --git a/packages/react/src/ReactStartTransition.js b/packages/react/src/ReactStartTransition.js new file mode 100644 index 0000000000000..d17f083c473c7 --- /dev/null +++ b/packages/react/src/ReactStartTransition.js @@ -0,0 +1,24 @@ +/** + * 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 + */ + +import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'; + +// Default to an arbitrarily large timeout. Effectively, this is infinite. The +// eventual goal is to never timeout when refreshing already visible content. +const IndefiniteTimeoutConfig = {timeoutMs: 100000}; + +export function startTransition(scope: () => void) { + const previousConfig = ReactCurrentBatchConfig.suspense; + ReactCurrentBatchConfig.suspense = IndefiniteTimeoutConfig; + try { + scope(); + } finally { + ReactCurrentBatchConfig.suspense = previousConfig; + } +}