Skip to content

Commit db3ae32

Browse files
author
Sunil Pai
authored
flush fallbacks in tests (#16240)
In this PR, for tests (specifically, code inside an `act()` scope), we immediately trigger work that would have otherwise required a timeout. This makes it simpler to tests loading/spinner states, and makes tests resilient to changes in React. For some of our tests(specifically, ReactSuspenseWithNoopRenderer-test.internal), we _don't_ want fallbacks to immediately trigger, because we're testing intermediate states and such. Added a feature flag `flushSuspenseFallbacksInTests` to disable this behaviour on a per case basis.
1 parent e6a0473 commit db3ae32

10 files changed

+126
-9
lines changed

packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,36 +32,63 @@ describe('ReactTestUtils.act()', () => {
3232
concurrentRoot = ReactDOM.unstable_createRoot(dom);
3333
concurrentRoot.render(el);
3434
}
35+
3536
function unmountConcurrent(_dom) {
3637
if (concurrentRoot !== null) {
3738
concurrentRoot.unmount();
3839
concurrentRoot = null;
3940
}
4041
}
41-
runActTests('concurrent mode', renderConcurrent, unmountConcurrent);
42+
43+
function rerenderConcurrent(el) {
44+
concurrentRoot.render(el);
45+
}
46+
47+
runActTests(
48+
'concurrent mode',
49+
renderConcurrent,
50+
unmountConcurrent,
51+
rerenderConcurrent,
52+
);
4253

4354
// and then in sync mode
55+
56+
let syncDom = null;
4457
function renderSync(el, dom) {
58+
syncDom = dom;
4559
ReactDOM.render(el, dom);
4660
}
61+
4762
function unmountSync(dom) {
63+
syncDom = null;
4864
ReactDOM.unmountComponentAtNode(dom);
4965
}
50-
runActTests('legacy sync mode', renderSync, unmountSync);
66+
67+
function rerenderSync(el) {
68+
ReactDOM.render(el, syncDom);
69+
}
70+
71+
runActTests('legacy sync mode', renderSync, unmountSync, rerenderSync);
5172

5273
// and then in batched mode
5374
let batchedRoot;
5475
function renderBatched(el, dom) {
5576
batchedRoot = ReactDOM.unstable_createSyncRoot(dom);
5677
batchedRoot.render(el);
5778
}
79+
5880
function unmountBatched(dom) {
5981
if (batchedRoot !== null) {
6082
batchedRoot.unmount();
6183
batchedRoot = null;
6284
}
6385
}
64-
runActTests('batched mode', renderBatched, unmountBatched);
86+
87+
function rerenderBatched(el) {
88+
batchedRoot.render(el);
89+
}
90+
91+
runActTests('batched mode', renderBatched, unmountBatched, rerenderBatched);
6592

6693
describe('unacted effects', () => {
6794
function App() {
@@ -117,7 +144,7 @@ describe('ReactTestUtils.act()', () => {
117144
});
118145
});
119146

120-
function runActTests(label, render, unmount) {
147+
function runActTests(label, render, unmount, rerender) {
121148
describe(label, () => {
122149
beforeEach(() => {
123150
jest.resetModules();
@@ -546,7 +573,7 @@ function runActTests(label, render, unmount) {
546573
expect(interactions.size).toBe(1);
547574
expectedInteraction = Array.from(interactions)[0];
548575

549-
render(<Component />, container);
576+
rerender(<Component />);
550577
},
551578
);
552579
});
@@ -576,7 +603,7 @@ function runActTests(label, render, unmount) {
576603
expect(interactions.size).toBe(1);
577604
expectedInteraction = Array.from(interactions)[0];
578605

579-
render(<Component />, secondContainer);
606+
rerender(<Component />);
580607
});
581608
},
582609
);
@@ -693,5 +720,70 @@ function runActTests(label, render, unmount) {
693720
}
694721
});
695722
});
723+
724+
describe('suspense', () => {
725+
it('triggers fallbacks if available', async () => {
726+
let resolved = false;
727+
let resolve;
728+
const promise = new Promise(_resolve => {
729+
resolve = _resolve;
730+
});
731+
732+
function Suspends() {
733+
if (resolved) {
734+
return 'was suspended';
735+
}
736+
throw promise;
737+
}
738+
739+
function App(props) {
740+
return (
741+
<React.Suspense
742+
fallback={<span data-test-id="spinner">loading...</span>}>
743+
{props.suspend ? <Suspends /> : 'content'}
744+
</React.Suspense>
745+
);
746+
}
747+
748+
// render something so there's content
749+
act(() => {
750+
render(<App suspend={false} />, container);
751+
});
752+
753+
// trigger a suspendy update
754+
act(() => {
755+
rerender(<App suspend={true} />);
756+
});
757+
expect(document.querySelector('[data-test-id=spinner]')).not.toBeNull();
758+
759+
// now render regular content again
760+
act(() => {
761+
rerender(<App suspend={false} />);
762+
});
763+
expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
764+
765+
// trigger a suspendy update with a delay
766+
React.unstable_withSuspenseConfig(
767+
() => {
768+
act(() => {
769+
rerender(<App suspend={true} />);
770+
});
771+
},
772+
{timeout: 5000},
773+
);
774+
// the spinner shows up regardless
775+
expect(document.querySelector('[data-test-id=spinner]')).not.toBeNull();
776+
777+
// resolve the promise
778+
await act(async () => {
779+
resolved = true;
780+
resolve();
781+
});
782+
783+
// spinner gone, content showing
784+
expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
785+
expect(container.textContent).toBe('was suspended');
786+
});
787+
});
696788
});
697789
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
enableSchedulerTracing,
2727
revertPassiveEffectsChange,
2828
warnAboutUnmockedScheduler,
29+
flushSuspenseFallbacksInTests,
2930
} from 'shared/ReactFeatureFlags';
3031
import ReactSharedInternals from 'shared/ReactSharedInternals';
3132
import invariant from 'shared/invariant';
@@ -993,7 +994,7 @@ function renderRoot(
993994
case RootIncomplete: {
994995
invariant(false, 'Should have a work-in-progress.');
995996
}
996-
// Flow knows about invariant, so it compains if I add a break statement,
997+
// Flow knows about invariant, so it complains if I add a break statement,
997998
// but eslint doesn't know about invariant, so it complains if I do.
998999
// eslint-disable-next-line no-fallthrough
9991000
case RootErrored: {
@@ -1027,7 +1028,12 @@ function renderRoot(
10271028
// possible.
10281029
const hasNotProcessedNewUpdates =
10291030
workInProgressRootLatestProcessedExpirationTime === Sync;
1030-
if (hasNotProcessedNewUpdates && !isSync) {
1031+
if (
1032+
hasNotProcessedNewUpdates &&
1033+
!isSync &&
1034+
// do not delay if we're inside an act() scope
1035+
!(flushSuspenseFallbacksInTests && IsThisRendererActing.current)
1036+
) {
10311037
// If we have not processed any new updates during this pass, then this is
10321038
// either a retry of an existing fallback state or a hidden tree.
10331039
// Hidden trees shouldn't be batched with other work and after that's
@@ -1064,7 +1070,11 @@ function renderRoot(
10641070
return commitRoot.bind(null, root);
10651071
}
10661072
case RootSuspendedWithDelay: {
1067-
if (!isSync) {
1073+
if (
1074+
!isSync &&
1075+
// do not delay if we're inside an act() scope
1076+
!(flushSuspenseFallbacksInTests && IsThisRendererActing.current)
1077+
) {
10681078
// We're suspended in a state that should be avoided. We'll try to avoid committing
10691079
// it for as long as the timeouts let us.
10701080
if (workInProgressRootHasPendingPing) {
@@ -1135,6 +1145,8 @@ function renderRoot(
11351145
// The work completed. Ready to commit.
11361146
if (
11371147
!isSync &&
1148+
// do not delay if we're inside an act() scope
1149+
!(flushSuspenseFallbacksInTests && IsThisRendererActing.current) &&
11381150
workInProgressRootLatestProcessedExpirationTime !== Sync &&
11391151
workInProgressRootCanSuspendUsingConfig !== null
11401152
) {
@@ -2439,6 +2451,7 @@ function warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber) {
24392451
}
24402452
}
24412453

2454+
// a 'shared' variable that changes when act() opens/closes in tests.
24422455
export const IsThisRendererActing = {current: (false: boolean)};
24432456

24442457
export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void {

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1515
ReactFeatureFlags = require('shared/ReactFeatureFlags');
1616
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
1717
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
18+
ReactFeatureFlags.flushSuspenseFallbacksInTests = false;
1819
React = require('react');
1920
Fragment = React.Fragment;
2021
ReactNoop = require('react-noop-renderer');

packages/shared/ReactFeatureFlags.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export const warnAboutUnmockedScheduler = false;
7474
// Temporary flag to revert the fix in #15650
7575
export const revertPassiveEffectsChange = false;
7676

77+
// For tests, we flush suspense fallbacks in an act scope;
78+
// *except* in some of our own tests, where we test incremental loading states.
79+
export const flushSuspenseFallbacksInTests = true;
80+
7781
// Changes priority of some events like mousemove to user-blocking priority,
7882
// but without making them discrete. The flag exists in case it causes
7983
// starvation problems.

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const enableFundamentalAPI = false;
3636
export const enableJSXTransformAPI = false;
3737
export const warnAboutUnmockedScheduler = true;
3838
export const revertPassiveEffectsChange = false;
39+
export const flushSuspenseFallbacksInTests = true;
3940
export const enableUserBlockingEvents = false;
4041
export const enableSuspenseCallback = false;
4142
export const warnAboutDefaultPropsOnFunctionComponents = false;

packages/shared/forks/ReactFeatureFlags.native-oss.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
3131
export const enableJSXTransformAPI = false;
3232
export const warnAboutUnmockedScheduler = false;
3333
export const revertPassiveEffectsChange = false;
34+
export const flushSuspenseFallbacksInTests = true;
3435
export const enableUserBlockingEvents = false;
3536
export const enableSuspenseCallback = false;
3637
export const warnAboutDefaultPropsOnFunctionComponents = false;

packages/shared/forks/ReactFeatureFlags.persistent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
3131
export const enableJSXTransformAPI = false;
3232
export const warnAboutUnmockedScheduler = true;
3333
export const revertPassiveEffectsChange = false;
34+
export const flushSuspenseFallbacksInTests = true;
3435
export const enableUserBlockingEvents = false;
3536
export const enableSuspenseCallback = false;
3637
export const warnAboutDefaultPropsOnFunctionComponents = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enableFundamentalAPI = false;
3131
export const enableJSXTransformAPI = false;
3232
export const warnAboutUnmockedScheduler = false;
3333
export const revertPassiveEffectsChange = false;
34+
export const flushSuspenseFallbacksInTests = true;
3435
export const enableUserBlockingEvents = false;
3536
export const enableSuspenseCallback = false;
3637
export const warnAboutDefaultPropsOnFunctionComponents = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enableFlareAPI = true;
3131
export const enableFundamentalAPI = false;
3232
export const enableJSXTransformAPI = true;
3333
export const warnAboutUnmockedScheduler = true;
34+
export const flushSuspenseFallbacksInTests = true;
3435
export const enableUserBlockingEvents = false;
3536
export const enableSuspenseCallback = true;
3637
export const warnAboutDefaultPropsOnFunctionComponents = false;

packages/shared/forks/ReactFeatureFlags.www.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export const enableSuspenseCallback = true;
8080

8181
export const warnAboutDefaultPropsOnFunctionComponents = false;
8282

83+
export const flushSuspenseFallbacksInTests = true;
84+
8385
// Flow magic to verify the exports of this file match the original version.
8486
// eslint-disable-next-line no-unused-vars
8587
type Check<_X, Y: _X, X: Y = _X> = null;

0 commit comments

Comments
 (0)