diff --git a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js index 3eea121d65b..a64a8d794ad 100644 --- a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js @@ -20,6 +20,8 @@ describe('React hooks DevTools integration', () => { let scheduleUpdate; let setSuspenseHandler; + global.IS_REACT_ACT_ENVIRONMENT = true; + beforeEach(() => { global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { inject: injected => { @@ -64,7 +66,7 @@ describe('React hooks DevTools integration', () => { expect(stateHook.isStateEditable).toBe(true); if (__DEV__) { - overrideHookState(fiber, stateHook.id, [], 10); + act(() => overrideHookState(fiber, stateHook.id, [], 10)); expect(renderer.toJSON()).toEqual({ type: 'div', props: {}, @@ -116,7 +118,7 @@ describe('React hooks DevTools integration', () => { expect(reducerHook.isStateEditable).toBe(true); if (__DEV__) { - overrideHookState(fiber, reducerHook.id, ['foo'], 'def'); + act(() => overrideHookState(fiber, reducerHook.id, ['foo'], 'def')); expect(renderer.toJSON()).toEqual({ type: 'div', props: {}, @@ -164,13 +166,12 @@ describe('React hooks DevTools integration', () => { expect(stateHook.isStateEditable).toBe(true); if (__DEV__) { - overrideHookState(fiber, stateHook.id, ['count'], 10); + act(() => overrideHookState(fiber, stateHook.id, ['count'], 10)); expect(renderer.toJSON()).toEqual({ type: 'div', props: {}, children: ['count:', '10'], }); - act(() => setStateFn(state => ({count: state.count + 1}))); expect(renderer.toJSON()).toEqual({ type: 'div', @@ -233,7 +234,8 @@ describe('React hooks DevTools integration', () => { } }); - it('should support overriding suspense in concurrent mode', () => { + // @gate __DEV__ + it('should support overriding suspense in concurrent mode', async () => { if (__DEV__) { // Lock the first render setSuspenseHandler(() => true); @@ -243,13 +245,15 @@ describe('React hooks DevTools integration', () => { return 'Done'; } - const renderer = ReactTestRenderer.create( -
- - - -
, - {unstable_isConcurrent: true}, + const renderer = await act(() => + ReactTestRenderer.create( +
+ + + +
, + {unstable_isConcurrent: true}, + ), ); expect(Scheduler).toFlushAndYield([]); diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js index 94ada274a77..758afc1e15f 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js @@ -17,8 +17,6 @@ import { } from '../../constants'; import REACT_VERSION from 'shared/ReactVersion'; -global.IS_REACT_ACT_ENVIRONMENT = true; - describe('getLanesFromTransportDecimalBitmask', () => { it('should return array of lane numbers from bitmask string', () => { expect(getLanesFromTransportDecimalBitmask('1')).toEqual([0]); @@ -210,6 +208,8 @@ describe('preprocessData', () => { tid = 0; pid = 0; startTime = 0; + + global.IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(() => { @@ -1251,7 +1251,7 @@ describe('preprocessData', () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = await preprocessData(testMarks); + const data = await act(() => preprocessData(testMarks)); expect(data.suspenseEvents).toHaveLength(1); expect(data.suspenseEvents[0].promiseName).toBe('Testing displayName'); } @@ -1367,6 +1367,8 @@ describe('preprocessData', () => { const root = ReactDOM.createRoot(document.createElement('div')); + // Temporarily turn off the act environment, since we're intentionally using Scheduler instead. + global.IS_REACT_ACT_ENVIRONMENT = false; React.startTransition(() => { // Start rendering an async update (but don't finish). root.render( @@ -1837,7 +1839,7 @@ describe('preprocessData', () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = await preprocessData(testMarks); + const data = await act(() => preprocessData(testMarks)); expect(data.suspenseEvents).toHaveLength(1); expect(data.suspenseEvents[0].warning).toMatchInlineSnapshot( `"A component suspended during an update which caused a fallback to be shown. Consider using the Transition API to avoid hiding components after they've been mounted."`, @@ -1895,7 +1897,7 @@ describe('preprocessData', () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = await preprocessData(testMarks); + const data = await act(() => preprocessData(testMarks)); expect(data.suspenseEvents).toHaveLength(1); expect(data.suspenseEvents[0].warning).toBe(null); } diff --git a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js index d0992dce53d..556772500ee 100644 --- a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js +++ b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js @@ -32,18 +32,31 @@ describe('ReactTestUtils.act()', () => { let concurrentRoot = null; const renderConcurrent = (el, dom) => { concurrentRoot = ReactDOM.createRoot(dom); - concurrentRoot.render(el); + if (__DEV__) { + act(() => concurrentRoot.render(el)); + } else { + concurrentRoot.render(el); + } }; const unmountConcurrent = _dom => { - if (concurrentRoot !== null) { - concurrentRoot.unmount(); - concurrentRoot = null; + if (__DEV__) { + act(() => { + if (concurrentRoot !== null) { + concurrentRoot.unmount(); + concurrentRoot = null; + } + }); + } else { + if (concurrentRoot !== null) { + concurrentRoot.unmount(); + concurrentRoot = null; + } } }; const rerenderConcurrent = el => { - concurrentRoot.render(el); + act(() => concurrentRoot.render(el)); }; runActTests( @@ -98,22 +111,29 @@ describe('ReactTestUtils.act()', () => { ]); }); + // @gate __DEV__ it('does not warn in concurrent mode', () => { const root = ReactDOM.createRoot(document.createElement('div')); - root.render(); + act(() => root.render()); Scheduler.unstable_flushAll(); }); it('warns in concurrent mode if root is strict', () => { + // TODO: We don't need this error anymore in concurrent mode because + // effects can only be scheduled as the result of an update, and we now + // enforce all updates must be wrapped with act, not just hook updates. expect(() => { const root = ReactDOM.createRoot(document.createElement('div'), { unstable_strictMode: true, }); root.render(); - Scheduler.unstable_flushAll(); - }).toErrorDev([ + }).toErrorDev( + 'An update to Root inside a test was not wrapped in act(...)', + {withoutStack: true}, + ); + expect(() => Scheduler.unstable_flushAll()).toErrorDev( 'An update to App ran an effect, but was not wrapped in act(...)', - ]); + ); }); }); }); diff --git a/packages/react-reconciler/src/ReactFiberAct.new.js b/packages/react-reconciler/src/ReactFiberAct.new.js index 18055e7c738..27102b5e925 100644 --- a/packages/react-reconciler/src/ReactFiberAct.new.js +++ b/packages/react-reconciler/src/ReactFiberAct.new.js @@ -12,11 +12,32 @@ import type {Fiber} from './ReactFiber.new'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {warnsIfNotActing} from './ReactFiberHostConfig'; -import {ConcurrentMode} from './ReactTypeOfMode'; const {ReactCurrentActQueue} = ReactSharedInternals; -export function isActEnvironment(fiber: Fiber) { +export function isLegacyActEnvironment(fiber: Fiber) { + if (__DEV__) { + // Legacy mode. We preserve the behavior of React 17's act. It assumes an + // act environment whenever `jest` is defined, but you can still turn off + // spurious warnings by setting IS_REACT_ACT_ENVIRONMENT explicitly + // to false. + + const isReactActEnvironmentGlobal = + // $FlowExpectedError – Flow doesn't know about IS_REACT_ACT_ENVIRONMENT global + typeof IS_REACT_ACT_ENVIRONMENT !== 'undefined' + ? IS_REACT_ACT_ENVIRONMENT + : undefined; + + // $FlowExpectedError - Flow doesn't know about jest + const jestIsDefined = typeof jest !== 'undefined'; + return ( + warnsIfNotActing && jestIsDefined && isReactActEnvironmentGlobal !== false + ); + } + return false; +} + +export function isConcurrentActEnvironment() { if (__DEV__) { const isReactActEnvironmentGlobal = // $FlowExpectedError – Flow doesn't know about IS_REACT_ACT_ENVIRONMENT global @@ -24,31 +45,14 @@ export function isActEnvironment(fiber: Fiber) { ? IS_REACT_ACT_ENVIRONMENT : undefined; - if (fiber.mode & ConcurrentMode) { - if ( - !isReactActEnvironmentGlobal && - ReactCurrentActQueue.current !== null - ) { - // TODO: Include link to relevant documentation page. - console.error( - 'The current testing environment is not configured to support ' + - 'act(...)', - ); - } - return isReactActEnvironmentGlobal; - } else { - // Legacy mode. We preserve the behavior of React 17's act. It assumes an - // act environment whenever `jest` is defined, but you can still turn off - // spurious warnings by setting IS_REACT_ACT_ENVIRONMENT explicitly - // to false. - // $FlowExpectedError - Flow doesn't know about jest - const jestIsDefined = typeof jest !== 'undefined'; - return ( - warnsIfNotActing && - jestIsDefined && - isReactActEnvironmentGlobal !== false + if (!isReactActEnvironmentGlobal && ReactCurrentActQueue.current !== null) { + // TODO: Include link to relevant documentation page. + console.error( + 'The current testing environment is not configured to support ' + + 'act(...)', ); } + return isReactActEnvironmentGlobal; } return false; } diff --git a/packages/react-reconciler/src/ReactFiberAct.old.js b/packages/react-reconciler/src/ReactFiberAct.old.js index ddae518dcb6..bcb4ad707a1 100644 --- a/packages/react-reconciler/src/ReactFiberAct.old.js +++ b/packages/react-reconciler/src/ReactFiberAct.old.js @@ -12,11 +12,32 @@ import type {Fiber} from './ReactFiber.old'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {warnsIfNotActing} from './ReactFiberHostConfig'; -import {ConcurrentMode} from './ReactTypeOfMode'; const {ReactCurrentActQueue} = ReactSharedInternals; -export function isActEnvironment(fiber: Fiber) { +export function isLegacyActEnvironment(fiber: Fiber) { + if (__DEV__) { + // Legacy mode. We preserve the behavior of React 17's act. It assumes an + // act environment whenever `jest` is defined, but you can still turn off + // spurious warnings by setting IS_REACT_ACT_ENVIRONMENT explicitly + // to false. + + const isReactActEnvironmentGlobal = + // $FlowExpectedError – Flow doesn't know about IS_REACT_ACT_ENVIRONMENT global + typeof IS_REACT_ACT_ENVIRONMENT !== 'undefined' + ? IS_REACT_ACT_ENVIRONMENT + : undefined; + + // $FlowExpectedError - Flow doesn't know about jest + const jestIsDefined = typeof jest !== 'undefined'; + return ( + warnsIfNotActing && jestIsDefined && isReactActEnvironmentGlobal !== false + ); + } + return false; +} + +export function isConcurrentActEnvironment() { if (__DEV__) { const isReactActEnvironmentGlobal = // $FlowExpectedError – Flow doesn't know about IS_REACT_ACT_ENVIRONMENT global @@ -24,31 +45,14 @@ export function isActEnvironment(fiber: Fiber) { ? IS_REACT_ACT_ENVIRONMENT : undefined; - if (fiber.mode & ConcurrentMode) { - if ( - !isReactActEnvironmentGlobal && - ReactCurrentActQueue.current !== null - ) { - // TODO: Include link to relevant documentation page. - console.error( - 'The current testing environment is not configured to support ' + - 'act(...)', - ); - } - return isReactActEnvironmentGlobal; - } else { - // Legacy mode. We preserve the behavior of React 17's act. It assumes an - // act environment whenever `jest` is defined, but you can still turn off - // spurious warnings by setting IS_REACT_ACT_ENVIRONMENT explicitly - // to false. - // $FlowExpectedError - Flow doesn't know about jest - const jestIsDefined = typeof jest !== 'undefined'; - return ( - warnsIfNotActing && - jestIsDefined && - isReactActEnvironmentGlobal !== false + if (!isReactActEnvironmentGlobal && ReactCurrentActQueue.current !== null) { + // TODO: Include link to relevant documentation page. + console.error( + 'The current testing environment is not configured to support ' + + 'act(...)', ); } + return isReactActEnvironmentGlobal; } return false; } diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index d21704a1b6f..266e98960cb 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -83,7 +83,6 @@ import { requestUpdateLane, requestEventTime, warnIfNotCurrentlyActingEffectsInDEV, - warnIfNotCurrentlyActingUpdatesInDev, markSkippedUpdateLanes, isInterleavedUpdate, } from './ReactFiberWorkLoop.new'; @@ -118,7 +117,6 @@ import { } from './ReactUpdateQueue.new'; import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; -import {isActEnvironment} from './ReactFiberAct.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -1679,9 +1677,7 @@ function mountEffect( deps: Array | void | null, ): void { if (__DEV__) { - if (isActEnvironment(currentlyRenderingFiber)) { - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); - } + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); } if ( __DEV__ && @@ -1709,9 +1705,7 @@ function updateEffect( deps: Array | void | null, ): void { if (__DEV__) { - if (isActEnvironment(currentlyRenderingFiber)) { - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); - } + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); } return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } @@ -2196,12 +2190,6 @@ function dispatchReducerAction( enqueueRenderPhaseUpdate(queue, update); } else { enqueueUpdate(fiber, queue, update, lane); - - if (__DEV__) { - if (isActEnvironment(fiber)) { - warnIfNotCurrentlyActingUpdatesInDev(fiber); - } - } const eventTime = requestEventTime(); const root = scheduleUpdateOnFiber(fiber, lane, eventTime); if (root !== null) { @@ -2282,11 +2270,6 @@ function dispatchSetState( } } } - if (__DEV__) { - if (isActEnvironment(fiber)) { - warnIfNotCurrentlyActingUpdatesInDev(fiber); - } - } const eventTime = requestEventTime(); const root = scheduleUpdateOnFiber(fiber, lane, eventTime); if (root !== null) { diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index cf6786e617d..b2378cbf30d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -83,7 +83,6 @@ import { requestUpdateLane, requestEventTime, warnIfNotCurrentlyActingEffectsInDEV, - warnIfNotCurrentlyActingUpdatesInDev, markSkippedUpdateLanes, isInterleavedUpdate, } from './ReactFiberWorkLoop.old'; @@ -118,7 +117,6 @@ import { } from './ReactUpdateQueue.old'; import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.old'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; -import {isActEnvironment} from './ReactFiberAct.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -1679,9 +1677,7 @@ function mountEffect( deps: Array | void | null, ): void { if (__DEV__) { - if (isActEnvironment(currentlyRenderingFiber)) { - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); - } + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); } if ( __DEV__ && @@ -1709,9 +1705,7 @@ function updateEffect( deps: Array | void | null, ): void { if (__DEV__) { - if (isActEnvironment(currentlyRenderingFiber)) { - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); - } + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber); } return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } @@ -2196,12 +2190,6 @@ function dispatchReducerAction( enqueueRenderPhaseUpdate(queue, update); } else { enqueueUpdate(fiber, queue, update, lane); - - if (__DEV__) { - if (isActEnvironment(fiber)) { - warnIfNotCurrentlyActingUpdatesInDev(fiber); - } - } const eventTime = requestEventTime(); const root = scheduleUpdateOnFiber(fiber, lane, eventTime); if (root !== null) { @@ -2282,11 +2270,6 @@ function dispatchSetState( } } } - if (__DEV__) { - if (isActEnvironment(fiber)) { - warnIfNotCurrentlyActingUpdatesInDev(fiber); - } - } const eventTime = requestEventTime(); const root = scheduleUpdateOnFiber(fiber, lane, eventTime); if (root !== null) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 930fc608f47..2c065d6ee17 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -236,6 +236,10 @@ import { } from './ReactFiberDevToolsHook.new'; import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors'; import {releaseCache} from './ReactFiberCacheComponent.new'; +import { + isLegacyActEnvironment, + isConcurrentActEnvironment, +} from './ReactFiberAct.new'; const ceil = Math.ceil; @@ -493,6 +497,8 @@ export function scheduleUpdateOnFiber( } } + warnIfUpdatesNotWrappedWithActDEV(fiber); + if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { if ( (executionContext & CommitContext) !== NoContext && @@ -2402,6 +2408,8 @@ export function pingSuspendedRoot( const eventTime = requestEventTime(); markRootPinged(root, pingedLanes, eventTime); + warnIfSuspenseResolutionNotWrappedWithActDEV(root); + if ( workInProgressRoot === root && isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes) @@ -2854,7 +2862,12 @@ function shouldForceFlushFallbacksInDEV() { export function warnIfNotCurrentlyActingEffectsInDEV(fiber: Fiber): void { if (__DEV__) { + const isActEnvironment = + fiber.mode & ConcurrentMode + ? isConcurrentActEnvironment() + : isLegacyActEnvironment(fiber); if ( + isActEnvironment && (fiber.mode & StrictLegacyMode) !== NoMode && ReactCurrentActQueue.current === null ) { @@ -2875,12 +2888,36 @@ export function warnIfNotCurrentlyActingEffectsInDEV(fiber: Fiber): void { } } -function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { +function warnIfUpdatesNotWrappedWithActDEV(fiber: Fiber): void { if (__DEV__) { - if ( - executionContext === NoContext && - ReactCurrentActQueue.current === null - ) { + if (fiber.mode & ConcurrentMode) { + if (!isConcurrentActEnvironment()) { + // Not in an act environment. No need to warn. + return; + } + } else { + // Legacy mode has additional cases where we suppress a warning. + if (!isLegacyActEnvironment(fiber)) { + // Not in an act environment. No need to warn. + return; + } + if (executionContext !== NoContext) { + // Legacy mode doesn't warn if the update is batched, i.e. + // batchedUpdates or flushSync. + return; + } + if ( + fiber.tag !== FunctionComponent && + fiber.tag !== ForwardRef && + fiber.tag !== SimpleMemoComponent + ) { + // For backwards compatibility with pre-hooks code, legacy mode only + // warns for updates that originate from a hook. + return; + } + } + + if (ReactCurrentActQueue.current === null) { const previousFiber = ReactCurrentFiberCurrent; try { setCurrentDebugFiberInDEV(fiber); @@ -2908,4 +2945,26 @@ function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { } } -export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV; +function warnIfSuspenseResolutionNotWrappedWithActDEV(root: FiberRoot): void { + if (__DEV__) { + if ( + root.tag !== LegacyRoot && + isConcurrentActEnvironment() && + ReactCurrentActQueue.current === null + ) { + console.error( + 'A suspended resource finished loading inside a test, but the event ' + + 'was not wrapped in act(...).\n\n' + + 'When testing, code that resolves suspended data should be wrapped ' + + 'into act(...):\n\n' + + 'act(() => {\n' + + ' /* finish loading suspended data */\n' + + '});\n' + + '/* assert on the output */\n\n' + + "This ensures that you're testing the behavior the user would see " + + 'in the browser.' + + ' Learn more at https://reactjs.org/link/wrap-tests-with-act', + ); + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 0b7f46c7990..97b835d3a18 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -236,6 +236,10 @@ import { } from './ReactFiberDevToolsHook.old'; import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors'; import {releaseCache} from './ReactFiberCacheComponent.old'; +import { + isLegacyActEnvironment, + isConcurrentActEnvironment, +} from './ReactFiberAct.old'; const ceil = Math.ceil; @@ -493,6 +497,8 @@ export function scheduleUpdateOnFiber( } } + warnIfUpdatesNotWrappedWithActDEV(fiber); + if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { if ( (executionContext & CommitContext) !== NoContext && @@ -2402,6 +2408,8 @@ export function pingSuspendedRoot( const eventTime = requestEventTime(); markRootPinged(root, pingedLanes, eventTime); + warnIfSuspenseResolutionNotWrappedWithActDEV(root); + if ( workInProgressRoot === root && isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes) @@ -2854,7 +2862,12 @@ function shouldForceFlushFallbacksInDEV() { export function warnIfNotCurrentlyActingEffectsInDEV(fiber: Fiber): void { if (__DEV__) { + const isActEnvironment = + fiber.mode & ConcurrentMode + ? isConcurrentActEnvironment() + : isLegacyActEnvironment(fiber); if ( + isActEnvironment && (fiber.mode & StrictLegacyMode) !== NoMode && ReactCurrentActQueue.current === null ) { @@ -2875,12 +2888,36 @@ export function warnIfNotCurrentlyActingEffectsInDEV(fiber: Fiber): void { } } -function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { +function warnIfUpdatesNotWrappedWithActDEV(fiber: Fiber): void { if (__DEV__) { - if ( - executionContext === NoContext && - ReactCurrentActQueue.current === null - ) { + if (fiber.mode & ConcurrentMode) { + if (!isConcurrentActEnvironment()) { + // Not in an act environment. No need to warn. + return; + } + } else { + // Legacy mode has additional cases where we suppress a warning. + if (!isLegacyActEnvironment(fiber)) { + // Not in an act environment. No need to warn. + return; + } + if (executionContext !== NoContext) { + // Legacy mode doesn't warn if the update is batched, i.e. + // batchedUpdates or flushSync. + return; + } + if ( + fiber.tag !== FunctionComponent && + fiber.tag !== ForwardRef && + fiber.tag !== SimpleMemoComponent + ) { + // For backwards compatibility with pre-hooks code, legacy mode only + // warns for updates that originate from a hook. + return; + } + } + + if (ReactCurrentActQueue.current === null) { const previousFiber = ReactCurrentFiberCurrent; try { setCurrentDebugFiberInDEV(fiber); @@ -2908,4 +2945,26 @@ function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { } } -export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV; +function warnIfSuspenseResolutionNotWrappedWithActDEV(root: FiberRoot): void { + if (__DEV__) { + if ( + root.tag !== LegacyRoot && + isConcurrentActEnvironment() && + ReactCurrentActQueue.current === null + ) { + console.error( + 'A suspended resource finished loading inside a test, but the event ' + + 'was not wrapped in act(...).\n\n' + + 'When testing, code that resolves suspended data should be wrapped ' + + 'into act(...):\n\n' + + 'act(() => {\n' + + ' /* finish loading suspended data */\n' + + '});\n' + + '/* assert on the output */\n\n' + + "This ensures that you're testing the behavior the user would see " + + 'in the browser.' + + ' Learn more at https://reactjs.org/link/wrap-tests-with-act', + ); + } + } +} diff --git a/packages/react-reconciler/src/__tests__/DebugTracing-test.internal.js b/packages/react-reconciler/src/__tests__/DebugTracing-test.internal.js index ee93b3f9929..8be5b0bb972 100644 --- a/packages/react-reconciler/src/__tests__/DebugTracing-test.internal.js +++ b/packages/react-reconciler/src/__tests__/DebugTracing-test.internal.js @@ -56,21 +56,17 @@ describe('DebugTracing', () => { expect(logs).toEqual([]); }); + // @gate build === 'development' // @gate experimental || www it('should not log anything for concurrent render without suspends or state updates', () => { - ReactTestRenderer.create( - -
- , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + +
+ , + {unstable_isConcurrent: true}, + ), ); - - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([]); }); @@ -81,12 +77,14 @@ describe('DebugTracing', () => { throw fakeSuspensePromise; } - ReactTestRenderer.create( - - - - - , + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + + + , + ), ); expect(logs).toEqual([ @@ -142,26 +140,33 @@ describe('DebugTracing', () => { // @gate experimental && build === 'development' && enableDebugTracing it('should log concurrent render with suspense', async () => { - const fakeSuspensePromise = Promise.resolve(true); + let isResolved = false; + let resolveFakeSuspensePromise; + const fakeSuspensePromise = new Promise(resolve => { + resolveFakeSuspensePromise = () => { + resolve(); + isResolved = true; + }; + }); + function Example() { - throw fakeSuspensePromise; + if (!isResolved) { + throw fakeSuspensePromise; + } + return null; } - ReactTestRenderer.create( - - - - - , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + + + , + {unstable_isConcurrent: true}, + ), ); - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([ `group: ⚛️ render (${DEFAULT_LANE_STRING})`, 'log: ⚛️ Example suspended', @@ -170,7 +175,7 @@ describe('DebugTracing', () => { logs.splice(0); - await fakeSuspensePromise; + await ReactTestRenderer.act(async () => await resolveFakeSuspensePromise()); expect(logs).toEqual(['log: ⚛️ Example resolved']); }); @@ -186,34 +191,23 @@ describe('DebugTracing', () => { return children; } - ReactTestRenderer.create( - - - - - - - , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + + + + + , + {unstable_isConcurrent: true}, + ), ); - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([ `group: ⚛️ render (${DEFAULT_LANE_STRING})`, 'log: ', `groupEnd: ⚛️ render (${DEFAULT_LANE_STRING})`, - ]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - - expect(logs).toEqual([ `group: ⚛️ render (${RETRY_LANE_STRING})`, 'log: ', `groupEnd: ⚛️ render (${RETRY_LANE_STRING})`, @@ -232,19 +226,15 @@ describe('DebugTracing', () => { } } - ReactTestRenderer.create( - - - , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + , + {unstable_isConcurrent: true}, + ), ); - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([ `group: ⚛️ commit (${DEFAULT_LANE_STRING})`, `group: ⚛️ layout effects (${DEFAULT_LANE_STRING})`, @@ -266,19 +256,15 @@ describe('DebugTracing', () => { } } - ReactTestRenderer.create( - - - , - {unstable_isConcurrent: true}, - ); - - expect(logs).toEqual([]); - - logs.splice(0); - expect(() => { - expect(Scheduler).toFlushUntilNextPaint([]); + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + , + {unstable_isConcurrent: true}, + ), + ); }).toErrorDev('Cannot update during an existing state transition'); expect(logs).toEqual([ @@ -298,19 +284,15 @@ describe('DebugTracing', () => { return didMount; } - ReactTestRenderer.create( - - - , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + , + {unstable_isConcurrent: true}, + ), ); - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([ `group: ⚛️ commit (${DEFAULT_LANE_STRING})`, `group: ⚛️ layout effects (${DEFAULT_LANE_STRING})`, @@ -378,19 +360,15 @@ describe('DebugTracing', () => { return null; } - ReactTestRenderer.create( - - - , - {unstable_isConcurrent: true}, + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + , + {unstable_isConcurrent: true}, + ), ); - expect(logs).toEqual([]); - - logs.splice(0); - - expect(Scheduler).toFlushUntilNextPaint([]); - expect(logs).toEqual([ `group: ⚛️ render (${DEFAULT_LANE_STRING})`, 'log: Hello from user code', @@ -398,6 +376,7 @@ describe('DebugTracing', () => { ]); }); + // @gate build === 'development' // @gate experimental || www it('should not log anything outside of a unstable_DebugTracingMode subtree', () => { function ExampleThatCascades() { @@ -417,16 +396,18 @@ describe('DebugTracing', () => { return null; } - ReactTestRenderer.create( - - - - - - - - - , + ReactTestRenderer.act(() => + ReactTestRenderer.create( + + + + + + + + + , + ), ); expect(logs).toEqual([]); diff --git a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js index 058e01b1fe0..324d1532733 100644 --- a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js +++ b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js @@ -12,6 +12,10 @@ let Scheduler; let ReactNoop; let useState; let act; +let Suspense; +let startTransition; +let getCacheForType; +let caches; // These tests are mostly concerned with concurrent roots. The legacy root // behavior is covered by other older test suites and is unchanged from @@ -24,11 +28,110 @@ describe('act warnings', () => { ReactNoop = require('react-noop-renderer'); act = React.unstable_act; useState = React.useState; + Suspense = React.Suspense; + startTransition = React.startTransition; + getCacheForType = React.unstable_getCacheForType; + caches = []; }); - function Text(props) { - Scheduler.unstable_yieldValue(props.text); - return props.text; + function createTextCache() { + 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 text; + } + + function AsyncText({text}) { + readText(text); + Scheduler.unstable_yieldValue(text); + return text; + } + + function resolveText(text) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } } function withActEnvironment(value, scope) { @@ -127,4 +230,132 @@ describe('act warnings', () => { expect(root).toMatchRenderedOutput('1'); }); }); + + test('warns if root update is not wrapped', () => { + withActEnvironment(true, () => { + const root = ReactNoop.createRoot(); + expect(() => root.render('Hi')).toErrorDev( + // TODO: Better error message that doesn't make it look like "Root" is + // the name of a custom component + 'An update to Root inside a test was not wrapped in act(...)', + {withoutStack: true}, + ); + }); + }); + + // @gate __DEV__ + test('warns if class update is not wrapped', () => { + let app; + class App extends React.Component { + state = {count: 0}; + render() { + app = this; + return ; + } + } + + withActEnvironment(true, () => { + const root = ReactNoop.createRoot(); + act(() => { + root.render(); + }); + expect(() => app.setState({count: 1})).toErrorDev( + 'An update to App inside a test was not wrapped in act(...)', + ); + }); + }); + + // @gate __DEV__ + test('warns even if update is synchronous', () => { + let setState; + function App() { + const [state, _setState] = useState(0); + setState = _setState; + return ; + } + + withActEnvironment(true, () => { + const root = ReactNoop.createRoot(); + act(() => root.render()); + expect(Scheduler).toHaveYielded([0]); + expect(root).toMatchRenderedOutput('0'); + + // Even though this update is synchronous, we should still fire a warning, + // because it could have spawned additional asynchronous work + expect(() => ReactNoop.flushSync(() => setState(1))).toErrorDev( + 'An update to App inside a test was not wrapped in act(...)', + ); + + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('1'); + }); + }); + + // @gate __DEV__ + // @gate enableCache + test('warns if Suspense retry is not wrapped', () => { + function App() { + return ( + }> + + + ); + } + + withActEnvironment(true, () => { + const root = ReactNoop.createRoot(); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + + // This is a retry, not a ping, because we already showed a fallback. + expect(() => + resolveText('Async'), + ).toErrorDev( + 'A suspended resource finished loading inside a test, but the event ' + + 'was not wrapped in act(...)', + {withoutStack: true}, + ); + }); + }); + + // @gate __DEV__ + // @gate enableCache + test('warns if Suspense ping is not wrapped', () => { + function App({showMore}) { + return ( + }> + {showMore ? : } + + ); + } + + withActEnvironment(true, () => { + const root = ReactNoop.createRoot(); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['(empty)']); + expect(root).toMatchRenderedOutput('(empty)'); + + act(() => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...']); + expect(root).toMatchRenderedOutput('(empty)'); + + // This is a ping, not a retry, because no fallback is showing. + expect(() => + resolveText('Async'), + ).toErrorDev( + 'A suspended resource finished loading inside a test, but the event ' + + 'was not wrapped in act(...)', + {withoutStack: true}, + ); + }); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 35c22e672ed..ee4bd306481 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -28,6 +28,8 @@ describe('ReactFiberHostContext', () => { .DefaultEventPriority; }); + global.IS_REACT_ACT_ENVIRONMENT = true; + // @gate __DEV__ it('works with null host context', async () => { let creates = 0; diff --git a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js index 135757d24f9..78458bd2d5b 100644 --- a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js @@ -23,12 +23,17 @@ describe('isomorphic act()', () => { act = React.unstable_act; }); + beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true; + }); + // @gate __DEV__ test('bypasses queueMicrotask', async () => { const root = ReactNoop.createRoot(); // First test what happens without wrapping in act. This update would // normally be queued in a microtask. + global.IS_REACT_ACT_ENVIRONMENT = false; ReactNoop.unstable_runWithPriority(DiscreteEventPriority, () => { root.render('A'); }); @@ -40,6 +45,7 @@ describe('isomorphic act()', () => { // Now do the same thing but wrap the update with `act`. No // `await` necessary. + global.IS_REACT_ACT_ENVIRONMENT = true; act(() => { ReactNoop.unstable_runWithPriority(DiscreteEventPriority, () => { root.render('B');