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;
+ }
+}