From ab6ad9fc0c25d08b148aee69958e4764ca10d1f1 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Mon, 19 Jul 2021 17:51:19 -0400 Subject: [PATCH] [Experimental] Add useInsertionEffect --- .../react-debug-tools/src/ReactDebugHooks.js | 14 + .../ReactHooksInspectionIntegration-test.js | 177 ++++++++ .../ReactDOMServerIntegrationHooks-test.js | 18 + .../src/server/ReactPartialRendererHooks.js | 17 + .../src/ReactFiberCommitWork.new.js | 21 +- .../src/ReactFiberCommitWork.old.js | 21 +- .../src/ReactFiberHooks.new.js | 79 ++++ .../src/ReactFiberHooks.old.js | 79 ++++ .../src/ReactHookEffectTags.js | 9 +- .../src/ReactInternalTypes.js | 5 + .../ReactHooksWithNoopRenderer-test.js | 411 ++++++++++++++++++ packages/react-server/src/ReactFizzHooks.js | 1 + .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 8 + 20 files changed, 862 insertions(+), 6 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 131a0003dcec7..7dfa44e8145a4 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.useInsertionEffect(() => {}); Dispatcher.useEffect(() => {}); Dispatcher.useImperativeHandle(undefined, () => null); Dispatcher.useDebugValue(null); @@ -191,6 +192,18 @@ function useLayoutEffect( }); } +function useInsertionEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + nextHook(); + hookLog.push({ + primitive: 'InsertionEffect', + stackError: new Error(), + value: create, + }); +} + function useEffect( create: () => (() => void) | void, inputs: Array | void | null, @@ -339,6 +352,7 @@ const Dispatcher: DispatcherType = { useImperativeHandle, useDebugValue, useLayoutEffect, + useInsertionEffect, 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 1c7cb75088408..b13bec22d213c 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 + it('should inspect the current state of all stateful hooks, including useInsertionEffect', () => { + const useInsertionEffect = React.unstable_useInsertionEffect; + 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'); + + useInsertionEffect(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: 'InsertionEffect', + 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: 'InsertionEffect', + 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..53a34b25ffd55 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 useInsertionEffect; let useLayoutEffect; let useDebugValue; let useOpaqueIdentifier; @@ -54,6 +55,7 @@ function initModules() { useRef = React.useRef; useDebugValue = React.useDebugValue; useImperativeHandle = React.useImperativeHandle; + useInsertionEffect = React.unstable_useInsertionEffect; useLayoutEffect = React.useLayoutEffect; useOpaqueIdentifier = React.unstable_useOpaqueIdentifier; forwardRef = React.forwardRef; @@ -638,6 +640,22 @@ describe('ReactDOMServerHooks', () => { expect(domNode.textContent).toEqual('Count: 0'); }); }); + describe('useInsertionEffect', () => { + // @gate experimental || www + it('should warn when invoked during render', async () => { + function Counter() { + useInsertionEffect(() => { + 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 0712d9fee9285..bfc21b0623a05 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 useInsertionEffect( + create: () => mixed, + inputs: Array | void | null, +) { + if (__DEV__) { + currentHookNameInDev = 'useInsertionEffect'; + console.error( + 'useInsertionEffect 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, useInsertionEffect should only be used in ' + + 'components that render exclusively on the client.', + ); + } +} + export function useLayoutEffect( create: () => (() => void) | void, inputs: Array | void | null, @@ -508,6 +524,7 @@ export const Dispatcher: DispatcherType = { useReducer, useRef, useState, + useInsertionEffect, 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 bde9173a28f69..85b5b778ba9f0 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, + Insertion as HookInsertion, Passive as HookPassive, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.new'; @@ -525,6 +526,8 @@ function commitHookEffectListMount(tag: HookFlags, finishedWork: Fiber) { let hookName; if ((effect.tag & HookLayout) !== NoFlags) { hookName = 'useLayoutEffect'; + } else if ((effect.tag & HookInsertion) !== NoFlags) { + hookName = 'useInsertionEffect'; } else { hookName = 'useEffect'; } @@ -1153,7 +1156,10 @@ function commitUnmount( do { const {destroy, tag} = effect; if (destroy !== undefined) { - if ((tag & HookLayout) !== NoHookEffect) { + if ( + (tag & HookInsertion) !== NoHookEffect || + (tag & HookLayout) !== NoHookEffect + ) { if ( enableProfilerTimer && enableProfilerCommitHooks && @@ -1738,6 +1744,13 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { case ForwardRef: case MemoComponent: case SimpleMemoComponent: { + commitHookEffectListUnmount( + HookInsertion | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork); + // Layout effects are destroyed during the mutation phase so that all // destroy functions for all fibers are called before any create functions. // This prevents sibling component effects from interfering with each other, @@ -1810,6 +1823,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { case ForwardRef: case MemoComponent: case SimpleMemoComponent: { + commitHookEffectListUnmount( + HookInsertion | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork); // Layout effects are destroyed during the mutation phase so that all // destroy functions for all fibers are called before any create functions. // This prevents sibling component effects from interfering with each other, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 241bc5a5dbae3..9613f0b168406 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, + Insertion as HookInsertion, Passive as HookPassive, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.old'; @@ -525,6 +526,8 @@ function commitHookEffectListMount(tag: HookFlags, finishedWork: Fiber) { let hookName; if ((effect.tag & HookLayout) !== NoFlags) { hookName = 'useLayoutEffect'; + } else if ((effect.tag & HookInsertion) !== NoFlags) { + hookName = 'useInsertionEffect'; } else { hookName = 'useEffect'; } @@ -1153,7 +1156,10 @@ function commitUnmount( do { const {destroy, tag} = effect; if (destroy !== undefined) { - if ((tag & HookLayout) !== NoHookEffect) { + if ( + (tag & HookInsertion) !== NoHookEffect || + (tag & HookLayout) !== NoHookEffect + ) { if ( enableProfilerTimer && enableProfilerCommitHooks && @@ -1738,6 +1744,13 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { case ForwardRef: case MemoComponent: case SimpleMemoComponent: { + commitHookEffectListUnmount( + HookInsertion | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork); + // Layout effects are destroyed during the mutation phase so that all // destroy functions for all fibers are called before any create functions. // This prevents sibling component effects from interfering with each other, @@ -1810,6 +1823,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { case ForwardRef: case MemoComponent: case SimpleMemoComponent: { + commitHookEffectListUnmount( + HookInsertion | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork); // Layout effects are destroyed during the mutation phase so that all // destroy functions for all fibers are called before any create functions. // This prevents sibling component effects from interfering with each other, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 64a4b446ba516..499c5cca98947 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -73,6 +73,7 @@ import { HasEffect as HookHasEffect, Layout as HookLayout, Passive as HookPassive, + Insertion as HookInsertion, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -1621,6 +1622,20 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return mountEffectImpl(UpdateEffect, HookInsertion, create, deps); +} + +function updateInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return updateEffectImpl(UpdateEffect, HookInsertion, create, deps); +} + function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2223,6 +2238,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, + useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, useMemo: throwInvalidHookError, useReducer: throwInvalidHookError, @@ -2250,6 +2266,7 @@ const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, + useInsertionEffect: mountInsertionEffect, useMemo: mountMemo, useReducer: mountReducer, useRef: mountRef, @@ -2275,6 +2292,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: updateReducer, @@ -2301,6 +2319,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: rerenderReducer, @@ -2381,6 +2400,15 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + mountHookTypesDev(); + checkDepsAreArrayDev(deps); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2515,6 +2543,14 @@ if (__DEV__) { updateHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2647,6 +2683,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2780,6 +2824,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2917,6 +2969,15 @@ if (__DEV__) { mountHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3065,6 +3126,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3214,6 +3284,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateInsertionEffect(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 11573e3b5e0a3..ca83b15260d98 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -73,6 +73,7 @@ import { HasEffect as HookHasEffect, Layout as HookLayout, Passive as HookPassive, + Insertion as HookInsertion, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -1621,6 +1622,20 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return mountEffectImpl(UpdateEffect, HookInsertion, create, deps); +} + +function updateInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + return updateEffectImpl(UpdateEffect, HookInsertion, create, deps); +} + function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2223,6 +2238,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, + useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, useMemo: throwInvalidHookError, useReducer: throwInvalidHookError, @@ -2250,6 +2266,7 @@ const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, + useInsertionEffect: mountInsertionEffect, useMemo: mountMemo, useReducer: mountReducer, useRef: mountRef, @@ -2275,6 +2292,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: updateReducer, @@ -2301,6 +2319,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useContext: readContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: rerenderReducer, @@ -2381,6 +2400,15 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + mountHookTypesDev(); + checkDepsAreArrayDev(deps); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2515,6 +2543,14 @@ if (__DEV__) { updateHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2647,6 +2683,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2780,6 +2824,14 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2917,6 +2969,15 @@ if (__DEV__) { mountHookTypesDev(); return mountImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3065,6 +3126,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateInsertionEffect(create, deps); + }, useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3214,6 +3284,15 @@ if (__DEV__) { updateHookTypesDev(); return updateImperativeHandle(ref, create, deps); }, + useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useInsertionEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateInsertionEffect(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..54be635a623a9 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 Insertion = /* */ 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 33bdb24af04db..f07fd4634b3d6 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' + | 'useInsertionEffect' | 'useLayoutEffect' | 'useCallback' | 'useMemo' @@ -286,6 +287,10 @@ export type Dispatcher = {| create: () => (() => void) | void, deps: Array | void | null, ): void, + useInsertionEffect( + 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 9069ca71c71e4..55b9abdcd7463 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 useInsertionEffect; let useLayoutEffect; let useCallback; let useMemo; @@ -46,6 +47,7 @@ describe('ReactHooksWithNoopRenderer', () => { useState = React.useState; useReducer = React.useReducer; useEffect = React.useEffect; + useInsertionEffect = React.unstable_useInsertionEffect; useLayoutEffect = React.useLayoutEffect; useCallback = React.useCallback; useMemo = React.useMemo; @@ -2682,6 +2684,415 @@ describe('ReactHooksWithNoopRenderer', () => { }); }); + describe('useInsertionEffect', () => { + // @gate experimental || www + it('fires insertion effects after snapshots on update', () => { + function CounterA(props) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue(`Create insertion`); + return () => { + Scheduler.unstable_yieldValue(`Destroy insertion`); + }; + }); + return null; + } + + class CounterB extends React.Component { + getSnapshotBeforeUpdate(prevProps, prevState) { + Scheduler.unstable_yieldValue(`Get Snapshot`); + return null; + } + + componentDidUpdate() {} + + render() { + return null; + } + } + + act(() => { + ReactNoop.render( + <> + + + , + ); + + expect(Scheduler).toFlushAndYield(['Create insertion']); + }); + + // Update + act(() => { + ReactNoop.render( + <> + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Get Snapshot', + 'Destroy insertion', + 'Create insertion', + ]); + }); + + // Unmount everything + act(() => { + ReactNoop.render(null); + + expect(Scheduler).toFlushAndYield(['Destroy insertion']); + }); + }); + + // @gate experimental || www + it('fires insertion effects before layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create insertion [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy insertion [current: ${committedText}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create layout [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Create passive [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy passive [current: ${committedText}]`, + ); + }; + }); + return null; + } + act(() => { + ReactNoop.render(); + + expect(Scheduler).toFlushUntilNextPaint([ + 'Create insertion [current: (empty)]', + 'Create layout [current: 0]', + ]); + expect(committedText).toEqual('0'); + }); + + expect(Scheduler).toHaveYielded(['Create passive [current: 0]']); + + // Unmount everything + act(() => { + ReactNoop.render(null); + + expect(Scheduler).toFlushUntilNextPaint([ + 'Destroy insertion [current: 0]', + 'Destroy layout [current: 0]', + ]); + }); + + expect(Scheduler).toHaveYielded(['Destroy passive [current: 0]']); + }); + + // @gate experimental || www + it('force flushes passive effects before firing new insertion effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create insertion [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy insertion [current: ${committedText}]`, + ); + }; + }); + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create layout [current: ${committedText}]`, + ); + committedText = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Create passive [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy passive [current: ${committedText}]`, + ); + }; + }); + return null; + } + + act(() => { + React.startTransition(() => { + ReactNoop.render(); + }); + expect(Scheduler).toFlushUntilNextPaint([ + 'Create insertion [current: (empty)]', + 'Create layout [current: 0]', + ]); + expect(committedText).toEqual('0'); + + React.startTransition(() => { + ReactNoop.render(); + }); + expect(Scheduler).toFlushUntilNextPaint([ + 'Create passive [current: 0]', + 'Destroy insertion [current: 0]', + 'Create insertion [current: 0]', + 'Destroy layout [current: 1]', + 'Create layout [current: 1]', + ]); + expect(committedText).toEqual('1'); + }); + expect(Scheduler).toHaveYielded([ + 'Destroy passive [current: 1]', + 'Create passive [current: 1]', + ]); + }); + + // @gate experimental || www + it('fires all insertion effects (interleaved) before firing any layout effects', () => { + let committedA = '(empty)'; + let committedB = '(empty)'; + + function CounterA(props) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + committedA = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + committedA = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create Layout 1 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy Layout 1 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create Layout 2 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy Layout 2 for Component A [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + function CounterB(props) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + committedB = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + useInsertionEffect(() => { + Scheduler.unstable_yieldValue( + `Create Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + committedB = props.count + ''; + return () => { + Scheduler.unstable_yieldValue( + `Destroy Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create Layout 1 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy Layout 1 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + + useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `Create Layout 2 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Destroy Layout 2 for Component B [A: ${committedA}, B: ${committedB}]`, + ); + }; + }); + return null; + } + + act(() => { + ReactNoop.render( + + + + , + ); + expect(Scheduler).toFlushAndYield([ + // All insertion effects fire before all layout effects + 'Create Insertion 1 for Component A [A: (empty), B: (empty)]', + 'Create Insertion 2 for Component A [A: 0, B: (empty)]', + 'Create Insertion 1 for Component B [A: 0, B: (empty)]', + 'Create Insertion 2 for Component B [A: 0, B: 0]', + 'Create Layout 1 for Component A [A: 0, B: 0]', + 'Create Layout 2 for Component A [A: 0, B: 0]', + 'Create Layout 1 for Component B [A: 0, B: 0]', + 'Create Layout 2 for Component B [A: 0, B: 0]', + ]); + expect([committedA, committedB]).toEqual(['0', '0']); + }); + + act(() => { + ReactNoop.render( + + + + , + ); + expect(Scheduler).toFlushAndYield([ + 'Destroy Insertion 1 for Component A [A: 0, B: 0]', + 'Destroy Insertion 2 for Component A [A: 0, B: 0]', + 'Create Insertion 1 for Component A [A: 0, B: 0]', + 'Create Insertion 2 for Component A [A: 1, B: 0]', + 'Destroy Layout 1 for Component A [A: 1, B: 0]', + 'Destroy Layout 2 for Component A [A: 1, B: 0]', + 'Destroy Insertion 1 for Component B [A: 1, B: 0]', + 'Destroy Insertion 2 for Component B [A: 1, B: 0]', + 'Create Insertion 1 for Component B [A: 1, B: 0]', + 'Create Insertion 2 for Component B [A: 1, B: 1]', + 'Destroy Layout 1 for Component B [A: 1, B: 1]', + 'Destroy Layout 2 for Component B [A: 1, B: 1]', + 'Create Layout 1 for Component A [A: 1, B: 1]', + 'Create Layout 2 for Component A [A: 1, B: 1]', + 'Create Layout 1 for Component B [A: 1, B: 1]', + 'Create Layout 2 for Component B [A: 1, B: 1]', + ]); + expect([committedA, committedB]).toEqual(['1', '1']); + + // Unmount everything + act(() => { + ReactNoop.render(null); + + expect(Scheduler).toFlushAndYield([ + 'Destroy Insertion 1 for Component A [A: 1, B: 1]', + 'Destroy Insertion 2 for Component A [A: 1, B: 1]', + 'Destroy Layout 1 for Component A [A: 1, B: 1]', + 'Destroy Layout 2 for Component A [A: 1, B: 1]', + 'Destroy Insertion 1 for Component B [A: 1, B: 1]', + 'Destroy Insertion 2 for Component B [A: 1, B: 1]', + 'Destroy Layout 1 for Component B [A: 1, B: 1]', + 'Destroy Layout 2 for Component B [A: 1, B: 1]', + ]); + }); + }); + }); + + // @gate experimental || www + it('assumes insertion effect destroy function is either a function or undefined', () => { + function App(props) { + useInsertionEffect(() => { + return props.return; + }); + return null; + } + + const root1 = ReactNoop.createRoot(); + expect(() => + act(() => { + root1.render(); + }), + ).toErrorDev([ + 'Warning: useInsertionEffect must not return anything besides a ' + + 'function, which is used for clean-up. You returned: 17', + ]); + + const root2 = ReactNoop.createRoot(); + expect(() => + act(() => { + root2.render(); + }), + ).toErrorDev([ + 'Warning: useInsertionEffect must not return anything besides a ' + + 'function, which is used for clean-up. You returned null. If your ' + + 'effect does not require clean up, return undefined (or nothing).', + ]); + + const root3 = ReactNoop.createRoot(); + expect(() => + act(() => { + root3.render(); + }), + ).toErrorDev([ + 'Warning: useInsertionEffect must not return anything besides a ' + + 'function, which is used for clean-up.\n\n' + + 'It looks like you wrote useInsertionEffect(async () => ...) or returned a Promise.', + ]); + + // Error on unmount because React assumes the value is a function + expect(() => + act(() => { + root3.unmount(); + }), + ).toThrow('is not a function'); + }); + }); + 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 51ea84e8c43d1..1618537ac1bd7 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -503,6 +503,7 @@ export const Dispatcher: DispatcherType = { useReducer, useRef, useState, + useInsertionEffect: 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 efac391833e8b..5429d3a3b9e60 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), + useInsertionEffect: (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 ca898150b7cd2..b4b57c68b5f56 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, + useInsertionEffect: unsupported, useLayoutEffect: unsupported, useCallback: unsupported, useImperativeHandle: unsupported, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 756f69f271336..7f8abc863157a 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -48,6 +48,7 @@ export { useEffect, useImperativeHandle, useLayoutEffect, + unstable_useInsertionEffect, useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 230e94f3a5280..a0a9bbaca216e 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -42,6 +42,7 @@ export { useDeferredValue, useEffect, useImperativeHandle, + unstable_useInsertionEffect, useLayoutEffect, useMemo, useMutableSource as unstable_useMutableSource, diff --git a/packages/react/index.js b/packages/react/index.js index c2285c6b0b312..ab1dd9c1a3ea7 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -67,6 +67,7 @@ export { useDeferredValue, useEffect, useImperativeHandle, + unstable_useInsertionEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index d87d7c891ee88..819d5fe90afd3 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_useInsertionEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 24421bd86c490..541e7a35d3d3e 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -41,6 +41,7 @@ import { useEffect, useImperativeHandle, useDebugValue, + useInsertionEffect, useLayoutEffect, useMemo, useMutableSource, @@ -91,6 +92,7 @@ export { useEffect, useImperativeHandle, useDebugValue, + useInsertionEffect as unstable_useInsertionEffect, useLayoutEffect, useMemo, useMutableSource, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index b284267b64e91..0722cf4b209b7 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 useInsertionEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + const dispatcher = resolveDispatcher(); + return dispatcher.useInsertionEffect(create, deps); +} + export function useLayoutEffect( create: () => (() => void) | void, deps: Array | void | null,