diff --git a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
index c369eb97ed4c7..883ee65a243ed 100644
--- a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
+++ b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
@@ -32,22 +32,43 @@ describe('ReactTestUtils.act()', () => {
concurrentRoot = ReactDOM.unstable_createRoot(dom);
concurrentRoot.render(el);
}
+
function unmountConcurrent(_dom) {
if (concurrentRoot !== null) {
concurrentRoot.unmount();
concurrentRoot = null;
}
}
- runActTests('concurrent mode', renderConcurrent, unmountConcurrent);
+
+ function rerenderConcurrent(el) {
+ concurrentRoot.render(el);
+ }
+
+ runActTests(
+ 'concurrent mode',
+ renderConcurrent,
+ unmountConcurrent,
+ rerenderConcurrent,
+ );
// and then in sync mode
+
+ let syncDom = null;
function renderSync(el, dom) {
+ syncDom = dom;
ReactDOM.render(el, dom);
}
+
function unmountSync(dom) {
+ syncDom = null;
ReactDOM.unmountComponentAtNode(dom);
}
- runActTests('legacy sync mode', renderSync, unmountSync);
+
+ function rerenderSync(el) {
+ ReactDOM.render(el, syncDom);
+ }
+
+ runActTests('legacy sync mode', renderSync, unmountSync, rerenderSync);
// and then in batched mode
let batchedRoot;
@@ -55,13 +76,19 @@ describe('ReactTestUtils.act()', () => {
batchedRoot = ReactDOM.unstable_createSyncRoot(dom);
batchedRoot.render(el);
}
+
function unmountBatched(dom) {
if (batchedRoot !== null) {
batchedRoot.unmount();
batchedRoot = null;
}
}
- runActTests('batched mode', renderBatched, unmountBatched);
+
+ function rerenderBatched(el) {
+ batchedRoot.render(el);
+ }
+
+ runActTests('batched mode', renderBatched, unmountBatched, rerenderBatched);
describe('unacted effects', () => {
function App() {
@@ -117,7 +144,7 @@ describe('ReactTestUtils.act()', () => {
});
});
-function runActTests(label, render, unmount) {
+function runActTests(label, render, unmount, rerender) {
describe(label, () => {
beforeEach(() => {
jest.resetModules();
@@ -546,7 +573,7 @@ function runActTests(label, render, unmount) {
expect(interactions.size).toBe(1);
expectedInteraction = Array.from(interactions)[0];
- render(, container);
+ rerender();
},
);
});
@@ -576,7 +603,7 @@ function runActTests(label, render, unmount) {
expect(interactions.size).toBe(1);
expectedInteraction = Array.from(interactions)[0];
- render(, secondContainer);
+ rerender();
});
},
);
@@ -693,5 +720,70 @@ function runActTests(label, render, unmount) {
}
});
});
+
+ describe('suspense', () => {
+ it('triggers fallbacks if available', async () => {
+ let resolved = false;
+ let resolve;
+ const promise = new Promise(_resolve => {
+ resolve = _resolve;
+ });
+
+ function Suspends() {
+ if (resolved) {
+ return 'was suspended';
+ }
+ throw promise;
+ }
+
+ function App(props) {
+ return (
+ loading...}>
+ {props.suspend ? : 'content'}
+
+ );
+ }
+
+ // render something so there's content
+ act(() => {
+ render(, container);
+ });
+
+ // trigger a suspendy update
+ act(() => {
+ rerender();
+ });
+ expect(document.querySelector('[data-test-id=spinner]')).not.toBeNull();
+
+ // now render regular content again
+ act(() => {
+ rerender();
+ });
+ expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
+
+ // trigger a suspendy update with a delay
+ React.unstable_withSuspenseConfig(
+ () => {
+ act(() => {
+ rerender();
+ });
+ },
+ {timeout: 5000},
+ );
+ // the spinner shows up regardless
+ expect(document.querySelector('[data-test-id=spinner]')).not.toBeNull();
+
+ // resolve the promise
+ await act(async () => {
+ resolved = true;
+ resolve();
+ });
+
+ // spinner gone, content showing
+ expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
+ expect(container.textContent).toBe('was suspended');
+ });
+ });
});
}
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index 53442df6e70ec..0af8817ff98ef 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -26,6 +26,7 @@ import {
enableSchedulerTracing,
revertPassiveEffectsChange,
warnAboutUnmockedScheduler,
+ flushSuspenseFallbacksInTests,
} from 'shared/ReactFeatureFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import invariant from 'shared/invariant';
@@ -993,7 +994,7 @@ function renderRoot(
case RootIncomplete: {
invariant(false, 'Should have a work-in-progress.');
}
- // Flow knows about invariant, so it compains if I add a break statement,
+ // Flow knows about invariant, so it complains if I add a break statement,
// but eslint doesn't know about invariant, so it complains if I do.
// eslint-disable-next-line no-fallthrough
case RootErrored: {
@@ -1027,7 +1028,12 @@ function renderRoot(
// possible.
const hasNotProcessedNewUpdates =
workInProgressRootLatestProcessedExpirationTime === Sync;
- if (hasNotProcessedNewUpdates && !isSync) {
+ if (
+ hasNotProcessedNewUpdates &&
+ !isSync &&
+ // do not delay if we're inside an act() scope
+ !(flushSuspenseFallbacksInTests && IsThisRendererActing.current)
+ ) {
// If we have not processed any new updates during this pass, then this is
// either a retry of an existing fallback state or a hidden tree.
// Hidden trees shouldn't be batched with other work and after that's
@@ -1064,7 +1070,11 @@ function renderRoot(
return commitRoot.bind(null, root);
}
case RootSuspendedWithDelay: {
- if (!isSync) {
+ if (
+ !isSync &&
+ // do not delay if we're inside an act() scope
+ !(flushSuspenseFallbacksInTests && IsThisRendererActing.current)
+ ) {
// We're suspended in a state that should be avoided. We'll try to avoid committing
// it for as long as the timeouts let us.
if (workInProgressRootHasPendingPing) {
@@ -1135,6 +1145,8 @@ function renderRoot(
// The work completed. Ready to commit.
if (
!isSync &&
+ // do not delay if we're inside an act() scope
+ !(flushSuspenseFallbacksInTests && IsThisRendererActing.current) &&
workInProgressRootLatestProcessedExpirationTime !== Sync &&
workInProgressRootCanSuspendUsingConfig !== null
) {
@@ -2439,6 +2451,7 @@ function warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber) {
}
}
+// a 'shared' variable that changes when act() opens/closes in tests.
export const IsThisRendererActing = {current: (false: boolean)};
export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void {
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
index 8c9773009b2b2..c95e058bb6af0 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
@@ -15,6 +15,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
+ ReactFeatureFlags.flushSuspenseFallbacksInTests = false;
React = require('react');
Fragment = React.Fragment;
ReactNoop = require('react-noop-renderer');
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index 083da1b75075a..6dc443b3e7ed8 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -74,6 +74,10 @@ export const warnAboutUnmockedScheduler = false;
// Temporary flag to revert the fix in #15650
export const revertPassiveEffectsChange = false;
+// For tests, we flush suspense fallbacks in an act scope;
+// *except* in some of our own tests, where we test incremental loading states.
+export const flushSuspenseFallbacksInTests = true;
+
// Changes priority of some events like mousemove to user-blocking priority,
// but without making them discrete. The flag exists in case it causes
// starvation problems.
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index 427db540edc87..7c6783463aa59 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -36,6 +36,7 @@ export const enableFundamentalAPI = false;
export const enableJSXTransformAPI = false;
export const warnAboutUnmockedScheduler = true;
export const revertPassiveEffectsChange = false;
+export const flushSuspenseFallbacksInTests = true;
export const enableUserBlockingEvents = false;
export const enableSuspenseCallback = false;
export const warnAboutDefaultPropsOnFunctionComponents = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index cf69b2dc91cb7..291e5d9c58b8f 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
export const enableJSXTransformAPI = false;
export const warnAboutUnmockedScheduler = false;
export const revertPassiveEffectsChange = false;
+export const flushSuspenseFallbacksInTests = true;
export const enableUserBlockingEvents = false;
export const enableSuspenseCallback = false;
export const warnAboutDefaultPropsOnFunctionComponents = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js
index 7258b2ae1a7cb..e62a6f88dc768 100644
--- a/packages/shared/forks/ReactFeatureFlags.persistent.js
+++ b/packages/shared/forks/ReactFeatureFlags.persistent.js
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
export const enableJSXTransformAPI = false;
export const warnAboutUnmockedScheduler = true;
export const revertPassiveEffectsChange = false;
+export const flushSuspenseFallbacksInTests = true;
export const enableUserBlockingEvents = false;
export const enableSuspenseCallback = false;
export const warnAboutDefaultPropsOnFunctionComponents = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index e9e16e4b3f7df..8b8013db7bd33 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
export const enableJSXTransformAPI = false;
export const warnAboutUnmockedScheduler = false;
export const revertPassiveEffectsChange = false;
+export const flushSuspenseFallbacksInTests = true;
export const enableUserBlockingEvents = false;
export const enableSuspenseCallback = false;
export const warnAboutDefaultPropsOnFunctionComponents = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index 86222406e391c..813691c9dfb14 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -31,6 +31,7 @@ export const enableFlareAPI = true;
export const enableFundamentalAPI = false;
export const enableJSXTransformAPI = true;
export const warnAboutUnmockedScheduler = true;
+export const flushSuspenseFallbacksInTests = true;
export const enableUserBlockingEvents = false;
export const enableSuspenseCallback = true;
export const warnAboutDefaultPropsOnFunctionComponents = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index f0123e67b41ae..e176657cad280 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -80,6 +80,8 @@ export const enableSuspenseCallback = true;
export const warnAboutDefaultPropsOnFunctionComponents = false;
+export const flushSuspenseFallbacksInTests = true;
+
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;