diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js
new file mode 100644
index 0000000000..99d6ccbb78
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js
@@ -0,0 +1,650 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+/* eslint-disable no-func-assign */
+
+'use strict';
+
+const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils');
+
+let React;
+let ReactFeatureFlags;
+let ReactDOM;
+let ReactDOMServer;
+let useState;
+let useReducer;
+let useEffect;
+let useContext;
+let useCallback;
+let useMemo;
+let useRef;
+let useAPI;
+let useMutationEffect;
+let useLayoutEffect;
+let forwardRef;
+let yieldedValues;
+let yieldValue;
+let clearYields;
+
+function initModules() {
+ // Reset warning cache.
+ jest.resetModuleRegistry();
+
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
+ ReactFeatureFlags.enableHooks = true;
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactDOMServer = require('react-dom/server');
+ useState = React.useState;
+ useReducer = React.useReducer;
+ useEffect = React.useEffect;
+ useContext = React.useContext;
+ useCallback = React.useCallback;
+ useMemo = React.useMemo;
+ useRef = React.useRef;
+ useAPI = React.useAPI;
+ useMutationEffect = React.useMutationEffect;
+ useLayoutEffect = React.useLayoutEffect;
+ forwardRef = React.forwardRef;
+
+ yieldedValues = [];
+ yieldValue = value => {
+ yieldedValues.push(value);
+ };
+ clearYields = () => {
+ const ret = yieldedValues;
+ yieldedValues = [];
+ return ret;
+ };
+
+ // Make them available to the helpers.
+ return {
+ ReactDOM,
+ ReactDOMServer,
+ };
+}
+
+const {
+ resetModules,
+ itRenders,
+ itThrowsWhenRendering,
+ serverRender,
+} = ReactDOMServerIntegrationUtils(initModules);
+
+describe('ReactDOMServerHooks', () => {
+ beforeEach(() => {
+ resetModules();
+ });
+
+ function Text(props) {
+ yieldValue(props.text);
+ return {props.text};
+ }
+
+ describe('useState', () => {
+ itRenders('basic render', async render => {
+ function Counter(props) {
+ const [count] = useState(0);
+ return Count: {count};
+ }
+
+ const domNode = await render();
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+
+ itRenders('lazy state initialization', async render => {
+ function Counter(props) {
+ const [count] = useState(() => {
+ return 0;
+ });
+ return Count: {count};
+ }
+
+ const domNode = await render();
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+
+ it('does not trigger a re-renders when updater is invoked outside current render function', async () => {
+ function UpdateCount({setCount, count, children}) {
+ if (count < 3) {
+ setCount(c => c + 1);
+ }
+ return {children};
+ }
+ function Counter() {
+ let [count, setCount] = useState(0);
+ return (
+
+
+ Count: {count}
+
+
+ );
+ }
+
+ const domNode = await serverRender();
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+
+ itThrowsWhenRendering(
+ 'if used inside a class component',
+ async render => {
+ class Counter extends React.Component {
+ render() {
+ let [count] = useState(0);
+ return ;
+ }
+ }
+
+ return render();
+ },
+ 'Hooks can only be called inside the body of a functional component.',
+ );
+
+ itRenders('multiple times when an updater is called', async render => {
+ function Counter() {
+ let [count, setCount] = useState(0);
+ if (count < 12) {
+ setCount(c => c + 1);
+ setCount(c => c + 1);
+ setCount(c => c + 1);
+ }
+ return ;
+ }
+
+ const domNode = await render();
+ expect(domNode.textContent).toEqual('Count: 12');
+ });
+
+ itRenders('until there are no more new updates', async render => {
+ function Counter() {
+ let [count, setCount] = useState(0);
+ if (count < 3) {
+ setCount(count + 1);
+ }
+ return Count: {count};
+ }
+
+ const domNode = await render();
+ expect(domNode.textContent).toEqual('Count: 3');
+ });
+
+ itThrowsWhenRendering(
+ 'after too many iterations',
+ async render => {
+ function Counter() {
+ let [count, setCount] = useState(0);
+ setCount(count + 1);
+ return {count};
+ }
+ return render();
+ },
+ 'Too many re-renders. React limits the number of renders to prevent ' +
+ 'an infinite loop.',
+ );
+ });
+
+ describe('useReducer', () => {
+ itRenders('with initial state', async render => {
+ function reducer(state, action) {
+ return action === 'increment' ? state + 1 : state;
+ }
+ function Counter() {
+ let [count] = useReducer(reducer, 0);
+ yieldValue('Render: ' + count);
+ return ;
+ }
+
+ const domNode = await render();
+
+ expect(clearYields()).toEqual(['Render: 0', 0]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('0');
+ });
+
+ itRenders('lazy initialization with initialAction', async render => {
+ function reducer(state, action) {
+ return action === 'increment' ? state + 1 : state;
+ }
+ function Counter() {
+ let [count] = useReducer(reducer, 0, 'increment');
+ yieldValue('Render: ' + count);
+ return ;
+ }
+
+ const domNode = await render();
+
+ expect(clearYields()).toEqual(['Render: 1', 1]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('1');
+ });
+
+ itRenders(
+ 'multiple times when updates happen during the render phase',
+ async render => {
+ function reducer(state, action) {
+ return action === 'increment' ? state + 1 : state;
+ }
+ function Counter() {
+ let [count, dispatch] = useReducer(reducer, 0);
+ if (count < 3) {
+ dispatch('increment');
+ }
+ yieldValue('Render: ' + count);
+ return ;
+ }
+
+ const domNode = await render();
+
+ expect(clearYields()).toEqual([
+ 'Render: 0',
+ 'Render: 1',
+ 'Render: 2',
+ 'Render: 3',
+ 3,
+ ]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('3');
+ },
+ );
+
+ itRenders(
+ 'using reducer passed at time of render, not time of dispatch',
+ async render => {
+ // This test is a bit contrived but it demonstrates a subtle edge case.
+
+ // Reducer A increments by 1. Reducer B increments by 10.
+ function reducerA(state, action) {
+ switch (action) {
+ case 'increment':
+ return state + 1;
+ case 'reset':
+ return 0;
+ }
+ }
+ function reducerB(state, action) {
+ switch (action) {
+ case 'increment':
+ return state + 10;
+ case 'reset':
+ return 0;
+ }
+ }
+
+ function Counter() {
+ let [reducer, setReducer] = useState(() => reducerA);
+ let [count, dispatch] = useReducer(reducer, 0);
+ if (count < 20) {
+ dispatch('increment');
+ // Swap reducers each time we increment
+ if (reducer === reducerA) {
+ setReducer(() => reducerB);
+ } else {
+ setReducer(() => reducerA);
+ }
+ }
+ yieldValue('Render: ' + count);
+ return ;
+ }
+
+ const domNode = await render();
+
+ expect(clearYields()).toEqual([
+ // The count should increase by alternating amounts of 10 and 1
+ // until we reach 21.
+ 'Render: 0',
+ 'Render: 10',
+ 'Render: 11',
+ 'Render: 21',
+ 21,
+ ]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('21');
+ },
+ );
+ });
+
+ describe('useMemo', () => {
+ itRenders('basic render', async render => {
+ function CapitalizedText(props) {
+ const text = props.text;
+ const capitalizedText = useMemo(
+ () => {
+ yieldValue(`Capitalize '${text}'`);
+ return text.toUpperCase();
+ },
+ [text],
+ );
+ return ;
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual(["Capitalize 'hello'", 'HELLO']);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('HELLO');
+ });
+
+ itRenders('if no inputs are provided', async render => {
+ function LazyCompute(props) {
+ const computed = useMemo(props.compute);
+ return ;
+ }
+
+ function computeA() {
+ yieldValue('compute A');
+ return 'A';
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual(['compute A', 'A']);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('A');
+ });
+
+ itRenders(
+ 'multiple times when updates happen during the render phase',
+ async render => {
+ function CapitalizedText(props) {
+ const [text, setText] = useState(props.text);
+ const capitalizedText = useMemo(
+ () => {
+ yieldValue(`Capitalize '${text}'`);
+ return text.toUpperCase();
+ },
+ [text],
+ );
+
+ if (text === 'hello') {
+ setText('hello, world.');
+ }
+ return ;
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual([
+ "Capitalize 'hello'",
+ "Capitalize 'hello, world.'",
+ 'HELLO, WORLD.',
+ ]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('HELLO, WORLD.');
+ },
+ );
+
+ itRenders(
+ 'should only invoke the memoized function when the inputs change',
+ async render => {
+ function CapitalizedText(props) {
+ const [text, setText] = useState(props.text);
+ const [count, setCount] = useState(0);
+ const capitalizedText = useMemo(
+ () => {
+ yieldValue(`Capitalize '${text}'`);
+ return text.toUpperCase();
+ },
+ [text],
+ );
+
+ yieldValue(count);
+
+ if (count < 3) {
+ setCount(count + 1);
+ }
+
+ if (text === 'hello' && count === 2) {
+ setText('hello, world.');
+ }
+ return ;
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual([
+ "Capitalize 'hello'",
+ 0,
+ 1,
+ 2,
+ // `capitalizedText` only recomputes when the text has changed
+ "Capitalize 'hello, world.'",
+ 3,
+ 'HELLO, WORLD.',
+ ]);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('HELLO, WORLD.');
+ },
+ );
+ });
+
+ describe('useRef', () => {
+ itRenders('basic render', async render => {
+ function Counter(props) {
+ const count = useRef(0);
+ return Count: {count.current};
+ }
+
+ const domNode = await render();
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+
+ itRenders(
+ 'multiple times when updates happen during the render phase',
+ async render => {
+ function Counter(props) {
+ const [count, setCount] = useState(0);
+ const ref = useRef(count);
+
+ if (count < 3) {
+ const newCount = count + 1;
+
+ ref.current = newCount;
+ setCount(newCount);
+ }
+
+ yieldValue(count);
+
+ return Count: {ref.current};
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual([0, 1, 2, 3]);
+ expect(domNode.textContent).toEqual('Count: 3');
+ },
+ );
+
+ itRenders(
+ 'always return the same reference through multiple renders',
+ async render => {
+ let firstRef = null;
+ function Counter(props) {
+ const [count, setCount] = useState(0);
+ const ref = useRef(count);
+ if (firstRef === null) {
+ firstRef = ref;
+ } else if (firstRef !== ref) {
+ throw new Error('should never change');
+ }
+
+ if (count < 3) {
+ setCount(count + 1);
+ } else {
+ firstRef = null;
+ }
+
+ yieldValue(count);
+
+ return Count: {ref.current};
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual([0, 1, 2, 3]);
+ expect(domNode.textContent).toEqual('Count: 0');
+ },
+ );
+ });
+
+ describe('useEffect', () => {
+ itRenders('should ignore effects on the server', async render => {
+ function Counter(props) {
+ useEffect(() => {
+ yieldValue('should not be invoked');
+ });
+ return ;
+ }
+ const domNode = await render();
+ expect(clearYields()).toEqual(['Count: 0']);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+ });
+
+ describe('useCallback', () => {
+ itRenders('should ignore callbacks on the server', async render => {
+ function Counter(props) {
+ useCallback(() => {
+ yieldValue('should not be invoked');
+ });
+ return ;
+ }
+ const domNode = await render();
+ expect(clearYields()).toEqual(['Count: 0']);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+ });
+
+ describe('useAPI', () => {
+ it('should not be invoked on the server', async () => {
+ function Counter(props, ref) {
+ useAPI(ref, () => {
+ throw new Error('should not be invoked');
+ });
+ return ;
+ }
+ Counter = forwardRef(Counter);
+ const counter = React.createRef();
+ counter.current = 0;
+ const domNode = await serverRender(
+ ,
+ );
+ expect(clearYields()).toEqual(['Count: 0']);
+ expect(domNode.tagName).toEqual('SPAN');
+ expect(domNode.textContent).toEqual('Count: 0');
+ });
+ });
+
+ describe('useMutationEffect', () => {
+ 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 () => {
+ function Counter() {
+ useLayoutEffect(() => {
+ 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('useContext', () => {
+ itRenders(
+ 'can use the same context multiple times in the same function',
+ async render => {
+ const Context = React.createContext(
+ {foo: 0, bar: 0, baz: 0},
+ (a, b) => {
+ let result = 0;
+ if (a.foo !== b.foo) {
+ result |= 0b001;
+ }
+ if (a.bar !== b.bar) {
+ result |= 0b010;
+ }
+ if (a.baz !== b.baz) {
+ result |= 0b100;
+ }
+ return result;
+ },
+ );
+
+ function Provider(props) {
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ function FooAndBar() {
+ const {foo} = useContext(Context, 0b001);
+ const {bar} = useContext(Context, 0b010);
+ return ;
+ }
+
+ function Baz() {
+ const {baz} = useContext(Context, 0b100);
+ return ;
+ }
+
+ class Indirection extends React.Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return this.props.children;
+ }
+ }
+
+ function App(props) {
+ return (
+
+ );
+ }
+
+ const domNode = await render();
+ expect(clearYields()).toEqual(['Foo: 1, Bar: 3', 'Baz: 5']);
+ expect(domNode.childNodes.length).toBe(2);
+ expect(domNode.firstChild.tagName).toEqual('SPAN');
+ expect(domNode.firstChild.textContent).toEqual('Foo: 1, Bar: 3');
+ expect(domNode.lastChild.tagName).toEqual('SPAN');
+ expect(domNode.lastChild.textContent).toEqual('Baz: 5');
+ },
+ );
+ });
+});
diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js
index 6b482be43e..1ee885c68a 100644
--- a/packages/react-dom/src/server/ReactPartialRenderer.js
+++ b/packages/react-dom/src/server/ReactPartialRenderer.js
@@ -48,6 +48,11 @@ import {
createMarkupForRoot,
} from './DOMMarkupOperations';
import escapeTextForBrowser from './escapeTextForBrowser';
+import {
+ prepareToUseHooks,
+ finishHooks,
+ Dispatcher,
+} from './ReactPartialRendererHooks';
import {
Namespaces,
getIntrinsicNamespace,
@@ -87,15 +92,6 @@ let pushCurrentDebugStack = (stack: Array) => {};
let pushElementToDebugStack = (element: ReactElement) => {};
let popCurrentDebugStack = () => {};
-let Dispatcher = {
- readContext(
- context: ReactContext,
- observedBits: void | number | boolean,
- ): T {
- return context._currentValue;
- },
-};
-
if (__DEV__) {
ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
@@ -573,7 +569,11 @@ function resolve(
}
}
}
+ const componentIdentity = {};
+ prepareToUseHooks(componentIdentity);
inst = Component(element.props, publicContext, updater);
+ inst = finishHooks(Component, element.props, inst, publicContext);
+
if (inst == null || inst.render == null) {
child = inst;
validateRenderResult(child, Component);
@@ -985,9 +985,17 @@ class ReactDOMServerRenderer {
switch (elementType.$$typeof) {
case REACT_FORWARD_REF_TYPE: {
const element: ReactElement = ((nextChild: any): ReactElement);
- const nextChildren = toArray(
- elementType.render(element.props, element.ref),
+ let nextChildren;
+ const componentIdentity = {};
+ prepareToUseHooks(componentIdentity);
+ nextChildren = elementType.render(element.props, element.ref);
+ nextChildren = finishHooks(
+ elementType.render,
+ element.props,
+ nextChildren,
+ element.ref,
);
+ nextChildren = toArray(nextChildren);
const frame: Frame = {
type: null,
domNamespace: parentNamespace,
diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js
new file mode 100644
index 0000000000..bc2d34d6d0
--- /dev/null
+++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js
@@ -0,0 +1,366 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+import type {ReactContext} from 'shared/ReactTypes';
+
+import invariant from 'shared/invariant';
+import warning from 'shared/warning';
+
+type BasicStateAction = S | (S => S);
+type MaybeCallback = void | null | (S => mixed);
+type Dispatch = (A, MaybeCallback) => void;
+
+type Update = {
+ action: A,
+ next: Update | null,
+};
+
+type UpdateQueue = {
+ last: Update | null,
+ dispatch: any,
+};
+
+type Hook = {
+ memoizedState: any,
+ queue: UpdateQueue | null,
+ next: Hook | null,
+};
+
+let currentlyRenderingComponent: Object | null = null;
+let firstWorkInProgressHook: Hook | null = null;
+let workInProgressHook: Hook | null = null;
+// Whether the work-in-progress hook is a re-rendered hook
+let isReRender: boolean = false;
+// Whether an update was scheduled during the currently executing render pass.
+let didScheduleRenderPhaseUpdate: boolean = false;
+// Lazily created map of render-phase updates
+let renderPhaseUpdates: Map<
+ UpdateQueue,
+ Update,
+> | null = null;
+// Counter to prevent infinite loops.
+let numberOfReRenders: number = 0;
+const RE_RENDER_LIMIT = 25;
+
+function resolveCurrentlyRenderingComponent(): Object {
+ invariant(
+ currentlyRenderingComponent !== null,
+ 'Hooks can only be called inside the body of a functional component.',
+ );
+ return currentlyRenderingComponent;
+}
+
+function createHook(): Hook {
+ return {
+ memoizedState: null,
+ queue: null,
+ next: null,
+ };
+}
+
+function createWorkInProgressHook(): Hook {
+ if (workInProgressHook === null) {
+ // This is the first hook in the list
+ if (firstWorkInProgressHook === null) {
+ isReRender = false;
+ firstWorkInProgressHook = workInProgressHook = createHook();
+ } else {
+ // There's already a work-in-progress. Reuse it.
+ isReRender = true;
+ workInProgressHook = firstWorkInProgressHook;
+ }
+ } else {
+ if (workInProgressHook.next === null) {
+ isReRender = false;
+ // Append to the end of the list
+ workInProgressHook = workInProgressHook.next = createHook();
+ } else {
+ // There's already a work-in-progress. Reuse it.
+ isReRender = true;
+ workInProgressHook = workInProgressHook.next;
+ }
+ }
+ return workInProgressHook;
+}
+
+export function prepareToUseHooks(componentIdentity: Object): void {
+ currentlyRenderingComponent = componentIdentity;
+
+ // The following should have already been reset
+ // didScheduleRenderPhaseUpdate = false;
+ // firstWorkInProgressHook = null;
+ // numberOfReRenders = 0;
+ // renderPhaseUpdates = null;
+ // workInProgressHook = null;
+}
+
+export function finishHooks(
+ Component: any,
+ props: any,
+ children: any,
+ refOrContext: any,
+): any {
+ // This must be called after every functional component to prevent hooks from
+ // being used in classes.
+
+ while (didScheduleRenderPhaseUpdate) {
+ // Updates were scheduled during the render phase. They are stored in
+ // the `renderPhaseUpdates` map. Call the component again, reusing the
+ // work-in-progress hooks and applying the additional updates on top. Keep
+ // restarting until no more updates are scheduled.
+ didScheduleRenderPhaseUpdate = false;
+ numberOfReRenders += 1;
+
+ // Start over from the beginning of the list
+ workInProgressHook = null;
+
+ children = Component(props, refOrContext);
+ }
+ currentlyRenderingComponent = null;
+ firstWorkInProgressHook = null;
+ numberOfReRenders = 0;
+ renderPhaseUpdates = null;
+ workInProgressHook = null;
+
+ // These were reset above
+ // currentlyRenderingComponent = null;
+ // didScheduleRenderPhaseUpdate = false;
+ // firstWorkInProgressHook = null;
+ // numberOfReRenders = 0;
+ // renderPhaseUpdates = null;
+ // workInProgressHook = null;
+
+ return children;
+}
+
+function useContext(
+ context: ReactContext,
+ observedBits: void | number | boolean,
+): T {
+ return context._currentValue;
+}
+
+function basicStateReducer(state: S, action: BasicStateAction): S {
+ return typeof action === 'function' ? action(state) : action;
+}
+
+export function useState(
+ initialState: S | (() => S),
+): [S, Dispatch>] {
+ return useReducer(
+ basicStateReducer,
+ // useReducer has a special case to support lazy useState initializers
+ (initialState: any),
+ );
+}
+
+export function useReducer(
+ reducer: (S, A) => S,
+ initialState: S,
+ initialAction: A | void | null,
+): [S, Dispatch] {
+ currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
+ workInProgressHook = createWorkInProgressHook();
+ if (isReRender) {
+ // This is a re-render. Apply the new render phase updates to the previous
+ // current hook.
+ const queue: UpdateQueue = (workInProgressHook.queue: any);
+ const dispatch: Dispatch = (queue.dispatch: any);
+ if (renderPhaseUpdates !== null) {
+ // Render phase updates are stored in a map of queue -> linked list
+ const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
+ if (firstRenderPhaseUpdate !== undefined) {
+ renderPhaseUpdates.delete(queue);
+ let newState = workInProgressHook.memoizedState;
+ let update = firstRenderPhaseUpdate;
+ do {
+ // Process this render phase update. We don't have to check the
+ // priority because it will always be the same as the current
+ // render's.
+ const action = update.action;
+ newState = reducer(newState, action);
+ update = update.next;
+ } while (update !== null);
+
+ workInProgressHook.memoizedState = newState;
+
+ return [newState, dispatch];
+ }
+ }
+ return [workInProgressHook.memoizedState, dispatch];
+ } else {
+ if (reducer === basicStateReducer) {
+ // Special case for `useState`.
+ if (typeof initialState === 'function') {
+ initialState = initialState();
+ }
+ } else if (initialAction !== undefined && initialAction !== null) {
+ initialState = reducer(initialState, initialAction);
+ }
+ workInProgressHook.memoizedState = initialState;
+ const queue: UpdateQueue = (workInProgressHook.queue = {
+ last: null,
+ dispatch: null,
+ });
+ const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind(
+ null,
+ currentlyRenderingComponent,
+ queue,
+ ): any));
+ return [workInProgressHook.memoizedState, dispatch];
+ }
+}
+
+function useMemo(
+ nextCreate: () => T,
+ inputs: Array | void | null,
+): T {
+ currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
+ workInProgressHook = createWorkInProgressHook();
+
+ const nextInputs =
+ inputs !== undefined && inputs !== null ? inputs : [nextCreate];
+
+ if (
+ workInProgressHook !== null &&
+ workInProgressHook.memoizedState !== null
+ ) {
+ const prevState = workInProgressHook.memoizedState;
+ const prevInputs = prevState[1];
+ if (inputsAreEqual(nextInputs, prevInputs)) {
+ return prevState[0];
+ }
+ }
+
+ const nextValue = nextCreate();
+ workInProgressHook.memoizedState = [nextValue, nextInputs];
+ return nextValue;
+}
+
+function useRef(initialValue: T): {current: T} {
+ currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
+ workInProgressHook = createWorkInProgressHook();
+ const previousRef = workInProgressHook.memoizedState;
+ if (previousRef === null) {
+ const ref = {current: initialValue};
+ if (__DEV__) {
+ Object.seal(ref);
+ }
+ workInProgressHook.memoizedState = ref;
+ return ref;
+ } else {
+ return previousRef;
+ }
+}
+
+function useMutationEffect(
+ create: () => mixed,
+ inputs: Array | void | null,
+) {
+ warning(
+ false,
+ '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: () => mixed,
+ inputs: Array | void | null,
+) {
+ warning(
+ false,
+ 'useLayoutEffect 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, useLayoutEffect should only be used in ' +
+ 'components that render exclusively on the client.',
+ );
+}
+
+function dispatchAction(
+ componentIdentity: Object,
+ queue: UpdateQueue,
+ action: A,
+) {
+ invariant(
+ numberOfReRenders < RE_RENDER_LIMIT,
+ 'Too many re-renders. React limits the number of renders to prevent ' +
+ 'an infinite loop.',
+ );
+
+ if (componentIdentity === currentlyRenderingComponent) {
+ // This is a render phase update. Stash it in a lazily-created map of
+ // queue -> linked list of updates. After this render pass, we'll restart
+ // and apply the stashed updates on top of the work-in-progress hook.
+ didScheduleRenderPhaseUpdate = true;
+ const update: Update = {
+ action,
+ next: null,
+ };
+ if (renderPhaseUpdates === null) {
+ renderPhaseUpdates = new Map();
+ }
+ const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
+ if (firstRenderPhaseUpdate === undefined) {
+ renderPhaseUpdates.set(queue, update);
+ } else {
+ // Append the update to the end of the list.
+ let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
+ while (lastRenderPhaseUpdate.next !== null) {
+ lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
+ }
+ lastRenderPhaseUpdate.next = update;
+ }
+ } else {
+ // This means an update has happened after the functional component has
+ // returned. On the server this is a no-op. In React Fiber, the update
+ // would be scheduled for a future render.
+ }
+}
+
+function inputsAreEqual(arr1, arr2) {
+ // Don't bother comparing lengths because these arrays are always
+ // passed inline.
+ for (let i = 0; i < arr1.length; i++) {
+ // Inlined Object.is polyfill.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
+ const val1 = arr1[i];
+ const val2 = arr2[i];
+ if (
+ (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) ||
+ (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare
+ ) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+}
+
+function noop(): void {}
+
+export const Dispatcher = {
+ readContext: useContext,
+ useContext,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+ useMutationEffect,
+ useLayoutEffect,
+ // useAPI is not run in the server environment
+ useAPI: noop,
+ // Callbacks are not run in the server environment.
+ useCallback: noop,
+ // Effects are not run in the server environment.
+ useEffect: noop,
+};
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index 8d61bcf127..439dffde48 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -347,47 +347,48 @@ export function useReducer(
): [S, Dispatch] {
currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
workInProgressHook = createWorkInProgressHook();
- if (isReRender) {
- // This is a re-render. Apply the new render phase updates to the previous
- // work-in-progress hook.
- const queue: UpdateQueue = (workInProgressHook.queue: any);
- const dispatch: Dispatch = (queue.dispatch: any);
- if (renderPhaseUpdates !== null) {
- // Render phase updates are stored in a map of queue -> linked list
- const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
- if (firstRenderPhaseUpdate !== undefined) {
- renderPhaseUpdates.delete(queue);
- let newState = workInProgressHook.memoizedState;
- let update = firstRenderPhaseUpdate;
- do {
- // Process this render phase update. We don't have to check the
- // priority because it will always be the same as the current
- // render's.
- const action = update.action;
- newState = reducer(newState, action);
- const callback = update.callback;
- if (callback !== null) {
- pushCallback(currentlyRenderingFiber, update);
+ let queue: UpdateQueue | null = (workInProgressHook.queue: any);
+ if (queue !== null) {
+ // Already have a queue, so this is an update.
+ if (isReRender) {
+ // This is a re-render. Apply the new render phase updates to the previous
+ // work-in-progress hook.
+ const dispatch: Dispatch = (queue.dispatch: any);
+ if (renderPhaseUpdates !== null) {
+ // Render phase updates are stored in a map of queue -> linked list
+ const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
+ if (firstRenderPhaseUpdate !== undefined) {
+ renderPhaseUpdates.delete(queue);
+ let newState = workInProgressHook.memoizedState;
+ let update = firstRenderPhaseUpdate;
+ do {
+ // Process this render phase update. We don't have to check the
+ // priority because it will always be the same as the current
+ // render's.
+ const action = update.action;
+ newState = reducer(newState, action);
+ const callback = update.callback;
+ if (callback !== null) {
+ pushCallback(currentlyRenderingFiber, update);
+ }
+ update = update.next;
+ } while (update !== null);
+
+ workInProgressHook.memoizedState = newState;
+
+ // Don't persist the state accumlated from the render phase updates to
+ // the base state unless the queue is empty.
+ // TODO: Not sure if this is the desired semantics, but it's what we
+ // do for gDSFP. I can't remember why.
+ if (workInProgressHook.baseUpdate === queue.last) {
+ workInProgressHook.baseState = newState;
}
- update = update.next;
- } while (update !== null);
- workInProgressHook.memoizedState = newState;
-
- // Don't persist the state accumlated from the render phase updates to
- // the base state unless the queue is empty.
- // TODO: Not sure if this is the desired semantics, but it's what we
- // do for gDSFP. I can't remember why.
- if (workInProgressHook.baseUpdate === queue.last) {
- workInProgressHook.baseState = newState;
+ return [newState, dispatch];
}
-
- return [newState, dispatch];
}
+ return [workInProgressHook.memoizedState, dispatch];
}
- return [workInProgressHook.memoizedState, dispatch];
- } else if (currentHook !== null) {
- const queue: UpdateQueue = (workInProgressHook.queue: any);
// The last update in the entire queue
const last = queue.last;
@@ -457,27 +458,28 @@ export function useReducer(
const dispatch: Dispatch = (queue.dispatch: any);
return [workInProgressHook.memoizedState, dispatch];
- } else {
- if (reducer === basicStateReducer) {
- // Special case for `useState`.
- if (typeof initialState === 'function') {
- initialState = initialState();
- }
- } else if (initialAction !== undefined && initialAction !== null) {
- initialState = reducer(initialState, initialAction);
- }
- workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
- const queue: UpdateQueue = (workInProgressHook.queue = {
- last: null,
- dispatch: null,
- });
- const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind(
- null,
- currentlyRenderingFiber,
- queue,
- ): any));
- return [workInProgressHook.memoizedState, dispatch];
}
+
+ // There's no existing queue, so this is the initial render.
+ if (reducer === basicStateReducer) {
+ // Special case for `useState`.
+ if (typeof initialState === 'function') {
+ initialState = initialState();
+ }
+ } else if (initialAction !== undefined && initialAction !== null) {
+ initialState = reducer(initialState, initialAction);
+ }
+ workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
+ queue = workInProgressHook.queue = {
+ last: null,
+ dispatch: null,
+ };
+ const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind(
+ null,
+ currentlyRenderingFiber,
+ queue,
+ ): any));
+ return [workInProgressHook.memoizedState, dispatch];
}
function pushCallback(workInProgress: Fiber, update: Update): void {
@@ -525,7 +527,8 @@ export function useRef(initialValue: T): {current: T} {
currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
workInProgressHook = createWorkInProgressHook();
let ref;
- if (currentHook === null) {
+
+ if (workInProgressHook.memoizedState === null) {
ref = {current: initialValue};
if (__DEV__) {
Object.seal(ref);
@@ -637,14 +640,13 @@ export function useCallback(
const nextInputs =
inputs !== undefined && inputs !== null ? inputs : [callback];
- if (currentHook !== null) {
- const prevState = currentHook.memoizedState;
+ const prevState = workInProgressHook.memoizedState;
+ if (prevState !== null) {
const prevInputs = prevState[1];
if (inputsAreEqual(nextInputs, prevInputs)) {
return prevState[0];
}
}
-
workInProgressHook.memoizedState = [callback, nextInputs];
return callback;
}
@@ -659,8 +661,8 @@ export function useMemo(
const nextInputs =
inputs !== undefined && inputs !== null ? inputs : [nextCreate];
- if (currentHook !== null) {
- const prevState = currentHook.memoizedState;
+ const prevState = workInProgressHook.memoizedState;
+ if (prevState !== null) {
const prevInputs = prevState[1];
if (inputsAreEqual(nextInputs, prevInputs)) {
return prevState[0];
diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
index f73b77abe2..28577022d1 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
@@ -1417,6 +1417,33 @@ describe('ReactHooks', () => {
ReactNoop.render();
expect(ReactNoop.flush()).toEqual(['compute B', 'B']);
});
+
+ it('should not invoke memoized function during re-renders unless inputs change', () => {
+ function LazyCompute(props) {
+ const computed = useMemo(() => props.compute(props.input), [
+ props.input,
+ ]);
+ const [count, setCount] = useState(0);
+ if (count < 3) {
+ setCount(count + 1);
+ }
+ return ;
+ }
+
+ function compute(val) {
+ ReactNoop.yield('compute ' + val);
+ return val;
+ }
+
+ ReactNoop.render();
+ expect(ReactNoop.flush()).toEqual(['compute A', 'A']);
+
+ ReactNoop.render();
+ expect(ReactNoop.flush()).toEqual(['A']);
+
+ ReactNoop.render();
+ expect(ReactNoop.flush()).toEqual(['compute B', 'B']);
+ });
});
describe('useRef', () => {
@@ -1476,6 +1503,30 @@ describe('ReactHooks', () => {
jest.advanceTimersByTime(20);
expect(ReactNoop.flush()).toEqual(['ping: 6']);
});
+
+ it('should return the same ref during re-renders', () => {
+ function Counter() {
+ const ref = useRef('val');
+ const [count, setCount] = useState(0);
+ const [firstRef] = useState(ref);
+
+ if (firstRef !== ref) {
+ throw new Error('should never change');
+ }
+
+ if (count < 3) {
+ setCount(count + 1);
+ }
+
+ return ;
+ }
+
+ ReactNoop.render();
+ expect(ReactNoop.flush()).toEqual(['val']);
+
+ ReactNoop.render();
+ expect(ReactNoop.flush()).toEqual(['val']);
+ });
});
describe('progressive enhancement', () => {