diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 7d2679b319ce5..433c614c3f40d 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -78,6 +78,7 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useCacheRefresh(); } Dispatcher.useLayoutEffect(() => {}); + Dispatcher.useMutationEffect(() => {}); Dispatcher.useEffect(() => {}); Dispatcher.useImperativeHandle(undefined, () => null); Dispatcher.useDebugValue(null); @@ -191,6 +192,18 @@ function useLayoutEffect( }); } +function useMutationEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + nextHook(); + hookLog.push({ + primitive: 'MutationEffect', + stackError: new Error(), + value: create, + }); +} + function useEffect( create: () => (() => void) | void, inputs: Array | void | null, @@ -320,6 +333,7 @@ const Dispatcher: DispatcherType = { useImperativeHandle, useDebugValue, useLayoutEffect, + useMutationEffect, useMemo, useReducer, useRef, diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 1a35fd284c906..ebceb84afdab8 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -268,6 +268,183 @@ describe('ReactHooksInspectionIntegration', () => { ]); }); + // @gate experimental || www + // @gate source // TODO: Build gets confused by the unstable prefix. + it('should inspect the current state of all stateful hooks, including useMutationEffect', () => { + const outsideRef = React.createRef(); + function effect() {} + function Foo(props) { + const [state1, setState] = React.useState('a'); + const [state2, dispatch] = React.useReducer((s, a) => a.value, 'b'); + const ref = React.useRef('c'); + + React.unstable_useMutationEffect(effect); + React.useLayoutEffect(effect); + React.useEffect(effect); + + React.useImperativeHandle( + outsideRef, + () => { + // Return a function so that jest treats them as non-equal. + return function Instance() {}; + }, + [], + ); + + React.useMemo(() => state1 + state2, [state1]); + + function update() { + act(() => { + setState('A'); + }); + act(() => { + dispatch({value: 'B'}); + }); + ref.current = 'C'; + } + const memoizedUpdate = React.useCallback(update, []); + return ( +
+ {state1} {state2} +
+ ); + } + let renderer; + act(() => { + renderer = ReactTestRenderer.create(); + }); + + let childFiber = renderer.root.findByType(Foo)._currentFiber(); + + const {onClick: updateStates} = renderer.root.findByType('div').props; + + let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + expect(tree).toEqual([ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'a', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'Reducer', + value: 'b', + subHooks: [], + }, + {isStateEditable: false, id: 2, name: 'Ref', value: 'c', subHooks: []}, + { + isStateEditable: false, + id: 3, + name: 'MutationEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 4, + name: 'LayoutEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 5, + name: 'Effect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 6, + name: 'ImperativeHandle', + value: outsideRef.current, + subHooks: [], + }, + { + isStateEditable: false, + id: 7, + name: 'Memo', + value: 'ab', + subHooks: [], + }, + { + isStateEditable: false, + id: 8, + name: 'Callback', + value: updateStates, + subHooks: [], + }, + ]); + + updateStates(); + + childFiber = renderer.root.findByType(Foo)._currentFiber(); + tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + + expect(tree).toEqual([ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'A', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'Reducer', + value: 'B', + subHooks: [], + }, + {isStateEditable: false, id: 2, name: 'Ref', value: 'C', subHooks: []}, + { + isStateEditable: false, + id: 3, + name: 'MutationEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 4, + name: 'LayoutEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 5, + name: 'Effect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 6, + name: 'ImperativeHandle', + value: outsideRef.current, + subHooks: [], + }, + { + isStateEditable: false, + id: 7, + name: 'Memo', + value: 'Ab', + subHooks: [], + }, + { + isStateEditable: false, + id: 8, + name: 'Callback', + value: updateStates, + subHooks: [], + }, + ]); + }); + it('should inspect the value of the current provider in useContext', () => { const MyContext = React.createContext('default'); function Foo(props) { diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index cfd23f843c140..88b4520f1dccd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -27,6 +27,7 @@ let useCallback; let useMemo; let useRef; let useImperativeHandle; +let useMutationEffect; let useLayoutEffect; let useDebugValue; let useOpaqueIdentifier; @@ -54,6 +55,7 @@ function initModules() { useRef = React.useRef; useDebugValue = React.useDebugValue; useImperativeHandle = React.useImperativeHandle; + useMutationEffect = React.unstable_useMutationEffect; useLayoutEffect = React.useLayoutEffect; useOpaqueIdentifier = React.unstable_useOpaqueIdentifier; forwardRef = React.forwardRef; @@ -638,6 +640,22 @@ describe('ReactDOMServerHooks', () => { expect(domNode.textContent).toEqual('Count: 0'); }); }); + describe('useMutationEffect', () => { + // @gate experimental || www + it('should warn when invoked during render', async () => { + function Counter() { + useMutationEffect(() => { + throw new Error('should not be invoked'); + }); + + return ; + } + const domNode = await serverRender(, 1); + expect(clearYields()).toEqual(['Count: 0']); + expect(domNode.tagName).toEqual('SPAN'); + expect(domNode.textContent).toEqual('Count: 0'); + }); + }); describe('useLayoutEffect', () => { it('should warn when invoked during render', async () => { diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 433ab9ae078b2..2012cd545daab 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -385,6 +385,22 @@ function useRef(initialValue: T): {|current: T|} { } } +function useMutationEffect( + create: () => mixed, + inputs: Array | void | null, +) { + if (__DEV__) { + currentHookNameInDev = 'useMutationEffect'; + console.error( + 'useMutationEffect does nothing on the server, because its effect cannot ' + + "be encoded into the server renderer's output format. This will lead " + + 'to a mismatch between the initial, non-hydrated UI and the intended ' + + 'UI. To avoid this, useMutationEffect should only be used in ' + + 'components that render exclusively on the client.', + ); + } +} + export function useLayoutEffect( create: () => (() => void) | void, inputs: Array | void | null, @@ -501,6 +517,7 @@ export const Dispatcher: DispatcherType = { useReducer, useRef, useState, + useMutationEffect, useLayoutEffect, useCallback, // useImperativeHandle is not run in the server environment diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 99161bd97f487..42fa94fac1eb9 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -136,6 +136,7 @@ import { NoFlags as NoHookEffect, HasEffect as HookHasEffect, Layout as HookLayout, + Mutation as HookMutation, Passive as HookPassive, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.new'; @@ -1143,7 +1144,10 @@ function commitUnmount( do { const {destroy, tag} = effect; if (destroy !== undefined) { - if ((tag & HookLayout) !== NoHookEffect) { + if ( + (tag & HookMutation) !== NoHookEffect || + (tag & HookLayout) !== NoHookEffect + ) { if ( enableProfilerTimer && enableProfilerCommitHooks && @@ -1745,6 +1749,15 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { ) { try { startLayoutEffectTimer(); + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount( + HookMutation | HookHasEffect, + finishedWork, + ); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1754,6 +1767,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { recordLayoutEffectDuration(finishedWork); } } else { + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1812,6 +1831,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { ) { try { startLayoutEffectTimer(); + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1821,6 +1846,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { recordLayoutEffectDuration(finishedWork); } } else { + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 8fcdb5920756e..b67bef7fdfbc8 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -136,6 +136,7 @@ import { NoFlags as NoHookEffect, HasEffect as HookHasEffect, Layout as HookLayout, + Mutation as HookMutation, Passive as HookPassive, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.old'; @@ -1143,7 +1144,10 @@ function commitUnmount( do { const {destroy, tag} = effect; if (destroy !== undefined) { - if ((tag & HookLayout) !== NoHookEffect) { + if ( + (tag & HookMutation) !== NoHookEffect || + (tag & HookLayout) !== NoHookEffect + ) { if ( enableProfilerTimer && enableProfilerCommitHooks && @@ -1745,6 +1749,15 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { ) { try { startLayoutEffectTimer(); + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount( + HookMutation | HookHasEffect, + finishedWork, + ); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1754,6 +1767,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { recordLayoutEffectDuration(finishedWork); } } else { + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1812,6 +1831,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { ) { try { startLayoutEffectTimer(); + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, @@ -1821,6 +1846,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { recordLayoutEffectDuration(finishedWork); } } else { + commitHookEffectListUnmount( + HookMutation | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookMutation | HookHasEffect, finishedWork); commitHookEffectListUnmount( HookLayout | HookHasEffect, finishedWork, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index b195a0cb57488..ad566271a0109 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -71,6 +71,7 @@ import { HasEffect as HookHasEffect, Layout as HookLayout, Passive as HookPassive, + Mutation as HookMutation, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -1484,6 +1485,20 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return mountEffectImpl(UpdateEffect, HookMutation, create, deps); +} + +function updateMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return updateEffectImpl(UpdateEffect, HookMutation, create, deps); +} + function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2069,6 +2084,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, + useMutationEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, useMemo: throwInvalidHookError, useReducer: throwInvalidHookError, @@ -2095,6 +2111,7 @@ const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, + useMutationEffect: mountMutationEffect, useMemo: mountMemo, useReducer: mountReducer, useRef: mountRef, @@ -2119,6 +2136,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useMutationEffect: updateMutationEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: updateReducer, @@ -2144,6 +2162,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useMutationEffect: updateMutationEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: rerenderReducer, @@ -2223,6 +2242,15 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + mountHookTypesDev(); + checkDepsAreArrayDev(deps); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2349,6 +2377,14 @@ if (__DEV__) { updateHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2473,6 +2509,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2598,6 +2642,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2727,6 +2779,15 @@ if (__DEV__) { mountHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2866,6 +2927,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3006,6 +3076,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 7c51c43ad2e7e..f0948bd4485c9 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -71,6 +71,7 @@ import { HasEffect as HookHasEffect, Layout as HookLayout, Passive as HookPassive, + Mutation as HookMutation, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -1484,6 +1485,20 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return mountEffectImpl(UpdateEffect, HookMutation, create, deps); +} + +function updateMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return updateEffectImpl(UpdateEffect, HookMutation, create, deps); +} + function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2069,6 +2084,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, + useMutationEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, useMemo: throwInvalidHookError, useReducer: throwInvalidHookError, @@ -2095,6 +2111,7 @@ const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, + useMutationEffect: mountMutationEffect, useMemo: mountMemo, useReducer: mountReducer, useRef: mountRef, @@ -2119,6 +2136,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useMutationEffect: updateMutationEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: updateReducer, @@ -2144,6 +2162,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useMutationEffect: updateMutationEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: rerenderReducer, @@ -2223,6 +2242,15 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + mountHookTypesDev(); + checkDepsAreArrayDev(deps); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2349,6 +2377,14 @@ if (__DEV__) { updateHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2473,6 +2509,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2598,6 +2642,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2727,6 +2779,15 @@ if (__DEV__) { mountHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2866,6 +2927,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3006,6 +3076,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useMutationEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutationEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/ReactHookEffectTags.js b/packages/react-reconciler/src/ReactHookEffectTags.js index 0ccd7e2ccbca1..67adbb06cc9ac 100644 --- a/packages/react-reconciler/src/ReactHookEffectTags.js +++ b/packages/react-reconciler/src/ReactHookEffectTags.js @@ -9,11 +9,12 @@ export type HookFlags = number; -export const NoFlags = /* */ 0b000; +export const NoFlags = /* */ 0b0000; // Represents whether effect should fire. -export const HasEffect = /* */ 0b001; +export const HasEffect = /* */ 0b0001; // Represents the phase in which the effect (not the clean-up) fires. -export const Layout = /* */ 0b010; -export const Passive = /* */ 0b100; +export const Mutation = /* */ 0b0010; +export const Layout = /* */ 0b0100; +export const Passive = /* */ 0b1000; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 5a4bc62374250..18d8ef9c50c6d 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -33,6 +33,7 @@ export type HookType = | 'useContext' | 'useRef' | 'useEffect' + | 'useMutationEffect' | 'useLayoutEffect' | 'useCallback' | 'useMemo' @@ -285,6 +286,10 @@ export type Dispatcher = {| create: () => (() => void) | void, deps: Array | void | null, ): void, + useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index 7de5232934486..b8a780bb5e64e 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -22,6 +22,7 @@ let Suspense; let useState; let useReducer; let useEffect; +let useMutationEffect; let useLayoutEffect; let useCallback; let useMemo; @@ -46,6 +47,7 @@ describe('ReactHooksWithNoopRenderer', () => { useState = React.useState; useReducer = React.useReducer; useEffect = React.useEffect; + useMutationEffect = React.unstable_useMutationEffect; useLayoutEffect = React.useLayoutEffect; useCallback = React.useCallback; useMemo = React.useMemo; @@ -2690,6 +2692,234 @@ describe('ReactHooksWithNoopRenderer', () => { }); }); + describe('useMutationEffect', () => { + // @gate experimental || www + it('fires mutation effects before layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useMutationEffect(() => { + Scheduler.unstable_yieldValue( + `Mount mutation [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Unmount mutation [current: ${committedText}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Mount layout [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Mount passive [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount passive [current: ${committedText}]`, + ); + }; + }); + return null; + } + act(() => { + ReactNoop.render(); + + expect(Scheduler).toFlushUntilNextPaint([ + 'Mount mutation [current: (empty)]', + 'Mount layout [current: 0]', + ]); + expect(committedText).toEqual('0'); + }); + + expect(Scheduler).toHaveYielded(['Mount passive [current: 0]']); + + // Unmount everything + act(() => { + ReactNoop.render(null); + + expect(Scheduler).toFlushUntilNextPaint([ + 'Unmount mutation [current: 0]', + 'Unmount layout [current: 0]', + ]); + }); + + expect(Scheduler).toHaveYielded(['Unmount passive [current: 0]']); + }); + + // @gate experimental || www + it('force flushes passive effects before firing new mutation effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useMutationEffect(() => { + Scheduler.unstable_yieldValue( + `Mount mutation [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Unmount mutation [current: ${committedText}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Mount layout [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Mount passive [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount passive [current: ${committedText}]`, + ); + }; + }); + return null; + } + + act(() => { + React.startTransition(() => { + ReactNoop.render(); + }); + expect(Scheduler).toFlushUntilNextPaint([ + 'Mount mutation [current: (empty)]', + 'Mount layout [current: 0]', + ]); + expect(committedText).toEqual('0'); + + React.startTransition(() => { + ReactNoop.render(); + }); + expect(Scheduler).toFlushUntilNextPaint([ + 'Mount passive [current: 0]', + 'Unmount mutation [current: 0]', + 'Mount mutation [current: 0]', + 'Unmount layout [current: 1]', + 'Mount layout [current: 1]', + ]); + expect(committedText).toEqual('1'); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount passive [current: 1]', + 'Mount passive [current: 1]', + ]); + }); + + // @gate experimental || www + it('fires all mutation effects before firing any layout effects', () => { + let committedA = '(empty)'; + let committedB = '(empty)'; + + function CounterA(props) { + useMutationEffect(() => { + Scheduler.unstable_yieldValue( + `Mount A mutation [A: ${committedA}, B: ${committedB}]`, + ); + committedA = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Unmount A mutation [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Mount layout A [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + function CounterB(props) { + useMutationEffect(() => { + Scheduler.unstable_yieldValue( + `Mount B mutation [A: ${committedA}, B: ${committedB}]`, + ); + committedB = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Unmount B mutation [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Mount layout B [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + act(() => { + ReactNoop.render( + + + + , + ); + expect(Scheduler).toFlushAndYield([ + // All mutation effects fire before all layout effects + 'Mount A mutation [A: (empty), B: (empty)]', + 'Mount B mutation [A: 0, B: (empty)]', + 'Mount layout A [A: 0, B: 0]', + 'Mount layout B [A: 0, B: 0]', + ]); + expect([committedA, committedB]).toEqual(['0', '0']); + }); + + act(() => { + ReactNoop.render( + + + + , + ); + expect(Scheduler).toFlushAndYield([ + // Note: This shows that the clean-up function of a layout effect is + // fired in the same phase as the set-up function of a mutation. + 'Unmount A mutation [A: 0, B: 0]', + 'Mount A mutation [A: 0, B: 0]', + 'Unmount layout A [A: 1, B: 0]', + 'Unmount B mutation [A: 1, B: 0]', + 'Mount B mutation [A: 1, B: 0]', + 'Unmount layout B [A: 1, B: 1]', + 'Mount layout A [A: 1, B: 1]', + 'Mount layout B [A: 1, B: 1]', + ]); + expect([committedA, committedB]).toEqual(['1', '1']); + }); + }); + }); + describe('useLayoutEffect', () => { it('fires layout effects after the host has been mutated', () => { function getCommittedText() { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 23a35c1803f70..57ba1624c3f50 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -496,6 +496,7 @@ export const Dispatcher: DispatcherType = { useReducer, useRef, useState, + useMutationEffect: noop, useLayoutEffect, useCallback, // useImperativeHandle is not run in the server environment diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index dd5b43df2d35e..66b0911c57b4b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -822,6 +822,7 @@ const Dispatcher: DispatcherType = { useReducer: (unsupportedHook: any), useRef: (unsupportedHook: any), useState: (unsupportedHook: any), + useMutationEffect: (unsupportedHook: any), useLayoutEffect: (unsupportedHook: any), useImperativeHandle: (unsupportedHook: any), useEffect: (unsupportedHook: any), diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index 43c6c5184d2b9..60b66a7304f36 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -35,6 +35,7 @@ export function waitForSuspense(fn: () => T): Promise { useReducer: unsupported, useRef: unsupported, useState: unsupported, + useMutationEffect: unsupported, useLayoutEffect: unsupported, useCallback: unsupported, useImperativeHandle: unsupported, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 653013c7b0797..15da69d002c3a 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -48,6 +48,7 @@ export { useEffect, useImperativeHandle, useLayoutEffect, + unstable_useMutationEffect, useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index ab90ea66bc112..d39e523152c74 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -42,6 +42,7 @@ export { useDeferredValue, useEffect, useImperativeHandle, + unstable_useMutationEffect, useLayoutEffect, useMemo, useMutableSource as unstable_useMutableSource, diff --git a/packages/react/index.js b/packages/react/index.js index 247d17ec01b8d..8e2838e343ff3 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -67,6 +67,7 @@ export { useDeferredValue, useEffect, useImperativeHandle, + unstable_useMutationEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 4f316eacad8b7..04375618c04c7 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -46,6 +46,7 @@ export { useDeferredValue as unstable_useDeferredValue, // TODO: Remove once call sights updated to useDeferredValue useEffect, useImperativeHandle, + unstable_useMutationEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 2b87d18b6c81d..5911c5c8652c7 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -41,6 +41,7 @@ import { useEffect, useImperativeHandle, useDebugValue, + useMutationEffect, useLayoutEffect, useMemo, useMutableSource, @@ -90,6 +91,7 @@ export { useEffect, useImperativeHandle, useDebugValue, + useMutationEffect as unstable_useMutationEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 82bd886d82455..e604c8211fa62 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -100,6 +100,14 @@ export function useEffect( return dispatcher.useEffect(create, deps); } +export function useMutationEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + const dispatcher = resolveDispatcher(); + return dispatcher.useMutationEffect(create, deps); +} + export function useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null,