diff --git a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js index 9ec5e562641eb..b5a8ede488e71 100644 --- a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +++ b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js @@ -1325,6 +1325,7 @@ function getReactiveHookCallbackIndex(calleeNode, options) { switch (node.name) { case 'useEffect': case 'useLayoutEffect': + case 'useHydrateableEffect': case 'useCallback': case 'useMemo': // useEffect(fn) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 99f7ae89056f7..a9d521f7eddc3 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -62,6 +62,7 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useReducer((s, a) => s, null); Dispatcher.useRef(null); Dispatcher.useLayoutEffect(() => {}); + Dispatcher.useHydrateableEffect(() => {}); Dispatcher.useEffect(() => {}); Dispatcher.useImperativeHandle(undefined, () => null); Dispatcher.useDebugValue(null); @@ -167,6 +168,18 @@ function useLayoutEffect( }); } +function useHydrateableEffect( + create: () => (() => void) | void, + inputs: Array | void | null, +): void { + nextHook(); + hookLog.push({ + primitive: 'HydrateableEffect', + stackError: new Error(), + value: create, + }); +} + function useEffect( create: () => (() => void) | void, inputs: Array | void | null, @@ -270,6 +283,7 @@ const Dispatcher: DispatcherType = { useImperativeHandle, useDebugValue, useLayoutEffect, + useHydrateableEffect, useMemo, useReducer, useRef, diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index 99743902a75e0..dc673b2d7013a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -12,6 +12,7 @@ let React = require('react'); let ReactDOM = require('react-dom'); let ReactDOMServer = require('react-dom/server'); +let ReactDOMTestUtils = require('react-dom/test-utils'); let Scheduler = require('scheduler'); describe('ReactDOMRoot', () => { @@ -23,6 +24,7 @@ describe('ReactDOMRoot', () => { React = require('react'); ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); + ReactDOMTestUtils = require('react-dom/test-utils'); Scheduler = require('scheduler'); }); @@ -229,4 +231,50 @@ describe('ReactDOMRoot', () => { Scheduler.unstable_flushAll(); ReactDOM.createRoot(container); // No warning }); + + describe('useHydratedEffect', () => { + function Component({value}) { + const [state, setState] = React.useState('initial'); + + React.useHydrateableEffect(() => { + setState(`${value}-effect`); + }); + + return

{state}

; + } + + it('behaves like useLayoutEffect when not hydrating', () => { + const root = ReactDOM.createBlockingRoot(container); + + root.render(); + Scheduler.unstable_advanceTime(0); + + expect(container.textContent).toEqual('undefined-effect'); + + root.render(); + Scheduler.unstable_advanceTime(0); + + expect(container.textContent).toEqual('rerender-effect'); + }); + + it('behaves like useEffect when hydrating', () => { + const root = ReactDOM.createBlockingRoot(container, {hydrate: true}); + const button = document.createElement('p'); + button.appendChild(document.createTextNode('initial')); + container.appendChild(button); + + ReactDOMTestUtils.act(() => { + root.render(); + Scheduler.unstable_advanceTime(0); + + expect(container.textContent).toEqual('initial'); + }); + expect(container.textContent).toEqual('undefined-effect'); + }); + + it('behaves like useEffect when server-side rendering', () => { + const markup = ReactDOMServer.renderToString(); + expect(markup).toEqual('

initial

'); + }); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index fddf54ac9c0d7..cc3fb9d656b3c 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -495,6 +495,7 @@ export const Dispatcher: DispatcherType = { useImperativeHandle: noop, // Effects are not run in the server environment. useEffect: noop, + useHydrateableEffect: noop, // Debugging effect useDebugValue: noop, useResponder, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 02191ffe5d906..32413c54427ea 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -56,6 +56,7 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import {getIsHydrating} from './ReactFiberHydrationContext'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -83,6 +84,10 @@ export type Dispatcher = {| create: () => (() => void) | void, deps: Array | void | null, ): void, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void, useCallback(callback: T, deps: Array | void | null): T, useMemo(nextCreate: () => T, deps: Array | void | null): T, useImperativeHandle( @@ -124,6 +129,7 @@ export type HookType = | 'useContext' | 'useRef' | 'useEffect' + | 'useHydrateableEffect' | 'useLayoutEffect' | 'useCallback' | 'useMemo' @@ -984,6 +990,17 @@ function updateEffect( ); } +function mountHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, +): void { + if (getIsHydrating()) { + return mountEffect(create, deps); + } else { + return mountLayoutEffect(create, deps); + } +} + function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -1379,6 +1396,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, + useHydrateableEffect: throwInvalidHookError, useMemo: throwInvalidHookError, useReducer: throwInvalidHookError, useRef: throwInvalidHookError, @@ -1397,6 +1415,7 @@ const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, + useHydrateableEffect: mountHydrateableEffect, useMemo: mountMemo, useReducer: mountReducer, useRef: mountRef, @@ -1415,6 +1434,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, + useHydrateableEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: updateReducer, useRef: updateRef, @@ -1433,6 +1453,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, + useHydrateableEffect: updateLayoutEffect, useMemo: updateMemo, useReducer: rerenderReducer, useRef: updateRef, @@ -1520,6 +1541,15 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + mountHookTypesDev(); + checkDepsAreArrayDev(deps); + return mountHydrateableEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; mountHookTypesDev(); @@ -1638,6 +1668,14 @@ if (__DEV__) { updateHookTypesDev(); return mountLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + updateHookTypesDev(); + return mountHydrateableEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; updateHookTypesDev(); @@ -1755,6 +1793,14 @@ if (__DEV__) { updateHookTypesDev(); return updateLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + updateHookTypesDev(); + return updateLayoutEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; updateHookTypesDev(); @@ -1872,6 +1918,14 @@ if (__DEV__) { updateHookTypesDev(); return updateLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + updateHookTypesDev(); + return updateLayoutEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; updateHookTypesDev(); @@ -1995,6 +2049,15 @@ if (__DEV__) { mountHookTypesDev(); return mountLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountHydrateableEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; warnInvalidHookAccess(); @@ -2126,6 +2189,15 @@ if (__DEV__) { updateHookTypesDev(); return updateLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateLayoutEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; warnInvalidHookAccess(); @@ -2257,6 +2329,15 @@ if (__DEV__) { updateHookTypesDev(); return updateLayoutEffect(create, deps); }, + useHydrateableEffect( + create: () => (() => void) | void, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useHydrateableEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateLayoutEffect(create, deps); + }, useMemo(create: () => T, deps: Array | void | null): T { currentHookNameInDev = 'useMemo'; warnInvalidHookAccess(); diff --git a/packages/react-refresh/src/ReactFreshBabelPlugin.js b/packages/react-refresh/src/ReactFreshBabelPlugin.js index 567a96d853ccc..a36e466faf62e 100644 --- a/packages/react-refresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-refresh/src/ReactFreshBabelPlugin.js @@ -220,6 +220,8 @@ export default function(babel, opts = {}) { case 'React.useEffect': case 'useLayoutEffect': case 'React.useLayoutEffect': + case 'useHydrateableEffect': + case 'React.useHydrateableEffect': case 'useMemo': case 'React.useMemo': case 'useCallback': diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 6c20c5700d975..2b1acaabd4a61 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -408,6 +408,7 @@ class ReactShallowRenderer { useEffect: noOp, useImperativeHandle: noOp, useLayoutEffect: noOp, + useHydrateableEffect: noOp, useMemo, useReducer, useRef, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index ad515bd321dfc..c0e3804a9408c 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -36,6 +36,7 @@ import { useImperativeHandle, useDebugValue, useLayoutEffect, + useHydrateableEffect, useMemo, useReducer, useRef, @@ -90,6 +91,7 @@ const React = { useImperativeHandle, useDebugValue, useLayoutEffect, + useHydrateableEffect, useMemo, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index c725b0e7c54e0..80666a43befe6 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -112,6 +112,14 @@ export function useLayoutEffect( return dispatcher.useLayoutEffect(create, deps); } +export function useHydrateableEffect( + create: () => (() => void) | void, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useHydrateableEffect(create, inputs); +} + export function useCallback( callback: T, deps: Array | void | null,