@samitouri / QOS-React / commits / dd019d34db

Add support for hooks to ReactDOMServer

Co-authored-by: Alex Taylor <alexmckenley@gmail.com> Co-authored-by: Andrew Clark <acdlite@fb.com>

Alex Taylor committed Sep 30, 2018 at 18:32 UTC dd019d34db1d8d067637033c36856c8b259cb35b
5 files changed +1149 -72
packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js new
+650
@@ -0,0 +1,650 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +/* eslint-disable no-func-assign */
11 +
12 +'use strict';
13 +
14 +const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils');
15 +
16 +let React;
17 +let ReactFeatureFlags;
18 +let ReactDOM;
19 +let ReactDOMServer;
20 +let useState;
21 +let useReducer;
22 +let useEffect;
23 +let useContext;
24 +let useCallback;
25 +let useMemo;
26 +let useRef;
27 +let useAPI;
28 +let useMutationEffect;
29 +let useLayoutEffect;
30 +let forwardRef;
31 +let yieldedValues;
32 +let yieldValue;
33 +let clearYields;
34 +
35 +function initModules() {
36 + // Reset warning cache.
37 + jest.resetModuleRegistry();
38 +
39 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
40 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
41 + ReactFeatureFlags.enableHooks = true;
42 + React = require('react');
43 + ReactDOM = require('react-dom');
44 + ReactDOMServer = require('react-dom/server');
45 + useState = React.useState;
46 + useReducer = React.useReducer;
47 + useEffect = React.useEffect;
48 + useContext = React.useContext;
49 + useCallback = React.useCallback;
50 + useMemo = React.useMemo;
51 + useRef = React.useRef;
52 + useAPI = React.useAPI;
53 + useMutationEffect = React.useMutationEffect;
54 + useLayoutEffect = React.useLayoutEffect;
55 + forwardRef = React.forwardRef;
56 +
57 + yieldedValues = [];
58 + yieldValue = value => {
59 + yieldedValues.push(value);
60 + };
61 + clearYields = () => {
62 + const ret = yieldedValues;
63 + yieldedValues = [];
64 + return ret;
65 + };
66 +
67 + // Make them available to the helpers.
68 + return {
69 + ReactDOM,
70 + ReactDOMServer,
71 + };
72 +}
73 +
74 +const {
75 + resetModules,
76 + itRenders,
77 + itThrowsWhenRendering,
78 + serverRender,
79 +} = ReactDOMServerIntegrationUtils(initModules);
80 +
81 +describe('ReactDOMServerHooks', () => {
82 + beforeEach(() => {
83 + resetModules();
84 + });
85 +
86 + function Text(props) {
87 + yieldValue(props.text);
88 + return <span>{props.text}</span>;
89 + }
90 +
91 + describe('useState', () => {
92 + itRenders('basic render', async render => {
93 + function Counter(props) {
94 + const [count] = useState(0);
95 + return <span>Count: {count}</span>;
96 + }
97 +
98 + const domNode = await render(<Counter />);
99 + expect(domNode.textContent).toEqual('Count: 0');
100 + });
101 +
102 + itRenders('lazy state initialization', async render => {
103 + function Counter(props) {
104 + const [count] = useState(() => {
105 + return 0;
106 + });
107 + return <span>Count: {count}</span>;
108 + }
109 +
110 + const domNode = await render(<Counter />);
111 + expect(domNode.textContent).toEqual('Count: 0');
112 + });
113 +
114 + it('does not trigger a re-renders when updater is invoked outside current render function', async () => {
115 + function UpdateCount({setCount, count, children}) {
116 + if (count < 3) {
117 + setCount(c => c + 1);
118 + }
119 + return <span>{children}</span>;
120 + }
121 + function Counter() {
122 + let [count, setCount] = useState(0);
123 + return (
124 + <div>
125 + <UpdateCount setCount={setCount} count={count}>
126 + Count: {count}
127 + </UpdateCount>
128 + </div>
129 + );
130 + }
131 +
132 + const domNode = await serverRender(<Counter />);
133 + expect(domNode.textContent).toEqual('Count: 0');
134 + });
135 +
136 + itThrowsWhenRendering(
137 + 'if used inside a class component',
138 + async render => {
139 + class Counter extends React.Component {
140 + render() {
141 + let [count] = useState(0);
142 + return <Text text={count} />;
143 + }
144 + }
145 +
146 + return render(<Counter />);
147 + },
148 + 'Hooks can only be called inside the body of a functional component.',
149 + );
150 +
151 + itRenders('multiple times when an updater is called', async render => {
152 + function Counter() {
153 + let [count, setCount] = useState(0);
154 + if (count < 12) {
155 + setCount(c => c + 1);
156 + setCount(c => c + 1);
157 + setCount(c => c + 1);
158 + }
159 + return <Text text={'Count: ' + count} />;
160 + }
161 +
162 + const domNode = await render(<Counter />);
163 + expect(domNode.textContent).toEqual('Count: 12');
164 + });
165 +
166 + itRenders('until there are no more new updates', async render => {
167 + function Counter() {
168 + let [count, setCount] = useState(0);
169 + if (count < 3) {
170 + setCount(count + 1);
171 + }
172 + return <span>Count: {count}</span>;
173 + }
174 +
175 + const domNode = await render(<Counter />);
176 + expect(domNode.textContent).toEqual('Count: 3');
177 + });
178 +
179 + itThrowsWhenRendering(
180 + 'after too many iterations',
181 + async render => {
182 + function Counter() {
183 + let [count, setCount] = useState(0);
184 + setCount(count + 1);
185 + return <span>{count}</span>;
186 + }
187 + return render(<Counter />);
188 + },
189 + 'Too many re-renders. React limits the number of renders to prevent ' +
190 + 'an infinite loop.',
191 + );
192 + });
193 +
194 + describe('useReducer', () => {
195 + itRenders('with initial state', async render => {
196 + function reducer(state, action) {
197 + return action === 'increment' ? state + 1 : state;
198 + }
199 + function Counter() {
200 + let [count] = useReducer(reducer, 0);
201 + yieldValue('Render: ' + count);
202 + return <Text text={count} />;
203 + }
204 +
205 + const domNode = await render(<Counter />);
206 +
207 + expect(clearYields()).toEqual(['Render: 0', 0]);
208 + expect(domNode.tagName).toEqual('SPAN');
209 + expect(domNode.textContent).toEqual('0');
210 + });
211 +
212 + itRenders('lazy initialization with initialAction', async render => {
213 + function reducer(state, action) {
214 + return action === 'increment' ? state + 1 : state;
215 + }
216 + function Counter() {
217 + let [count] = useReducer(reducer, 0, 'increment');
218 + yieldValue('Render: ' + count);
219 + return <Text text={count} />;
220 + }
221 +
222 + const domNode = await render(<Counter />);
223 +
224 + expect(clearYields()).toEqual(['Render: 1', 1]);
225 + expect(domNode.tagName).toEqual('SPAN');
226 + expect(domNode.textContent).toEqual('1');
227 + });
228 +
229 + itRenders(
230 + 'multiple times when updates happen during the render phase',
231 + async render => {
232 + function reducer(state, action) {
233 + return action === 'increment' ? state + 1 : state;
234 + }
235 + function Counter() {
236 + let [count, dispatch] = useReducer(reducer, 0);
237 + if (count < 3) {
238 + dispatch('increment');
239 + }
240 + yieldValue('Render: ' + count);
241 + return <Text text={count} />;
242 + }
243 +
244 + const domNode = await render(<Counter />);
245 +
246 + expect(clearYields()).toEqual([
247 + 'Render: 0',
248 + 'Render: 1',
249 + 'Render: 2',
250 + 'Render: 3',
251 + 3,
252 + ]);
253 + expect(domNode.tagName).toEqual('SPAN');
254 + expect(domNode.textContent).toEqual('3');
255 + },
256 + );
257 +
258 + itRenders(
259 + 'using reducer passed at time of render, not time of dispatch',
260 + async render => {
261 + // This test is a bit contrived but it demonstrates a subtle edge case.
262 +
263 + // Reducer A increments by 1. Reducer B increments by 10.
264 + function reducerA(state, action) {
265 + switch (action) {
266 + case 'increment':
267 + return state + 1;
268 + case 'reset':
269 + return 0;
270 + }
271 + }
272 + function reducerB(state, action) {
273 + switch (action) {
274 + case 'increment':
275 + return state + 10;
276 + case 'reset':
277 + return 0;
278 + }
279 + }
280 +
281 + function Counter() {
282 + let [reducer, setReducer] = useState(() => reducerA);
283 + let [count, dispatch] = useReducer(reducer, 0);
284 + if (count < 20) {
285 + dispatch('increment');
286 + // Swap reducers each time we increment
287 + if (reducer === reducerA) {
288 + setReducer(() => reducerB);
289 + } else {
290 + setReducer(() => reducerA);
291 + }
292 + }
293 + yieldValue('Render: ' + count);
294 + return <Text text={count} />;
295 + }
296 +
297 + const domNode = await render(<Counter />);
298 +
299 + expect(clearYields()).toEqual([
300 + // The count should increase by alternating amounts of 10 and 1
301 + // until we reach 21.
302 + 'Render: 0',
303 + 'Render: 10',
304 + 'Render: 11',
305 + 'Render: 21',
306 + 21,
307 + ]);
308 + expect(domNode.tagName).toEqual('SPAN');
309 + expect(domNode.textContent).toEqual('21');
310 + },
311 + );
312 + });
313 +
314 + describe('useMemo', () => {
315 + itRenders('basic render', async render => {
316 + function CapitalizedText(props) {
317 + const text = props.text;
318 + const capitalizedText = useMemo(
319 + () => {
320 + yieldValue(`Capitalize '${text}'`);
321 + return text.toUpperCase();
322 + },
323 + [text],
324 + );
325 + return <Text text={capitalizedText} />;
326 + }
327 +
328 + const domNode = await render(<CapitalizedText text="hello" />);
329 + expect(clearYields()).toEqual(["Capitalize 'hello'", 'HELLO']);
330 + expect(domNode.tagName).toEqual('SPAN');
331 + expect(domNode.textContent).toEqual('HELLO');
332 + });
333 +
334 + itRenders('if no inputs are provided', async render => {
335 + function LazyCompute(props) {
336 + const computed = useMemo(props.compute);
337 + return <Text text={computed} />;
338 + }
339 +
340 + function computeA() {
341 + yieldValue('compute A');
342 + return 'A';
343 + }
344 +
345 + const domNode = await render(<LazyCompute compute={computeA} />);
346 + expect(clearYields()).toEqual(['compute A', 'A']);
347 + expect(domNode.tagName).toEqual('SPAN');
348 + expect(domNode.textContent).toEqual('A');
349 + });
350 +
351 + itRenders(
352 + 'multiple times when updates happen during the render phase',
353 + async render => {
354 + function CapitalizedText(props) {
355 + const [text, setText] = useState(props.text);
356 + const capitalizedText = useMemo(
357 + () => {
358 + yieldValue(`Capitalize '${text}'`);
359 + return text.toUpperCase();
360 + },
361 + [text],
362 + );
363 +
364 + if (text === 'hello') {
365 + setText('hello, world.');
366 + }
367 + return <Text text={capitalizedText} />;
368 + }
369 +
370 + const domNode = await render(<CapitalizedText text="hello" />);
371 + expect(clearYields()).toEqual([
372 + "Capitalize 'hello'",
373 + "Capitalize 'hello, world.'",
374 + 'HELLO, WORLD.',
375 + ]);
376 + expect(domNode.tagName).toEqual('SPAN');
377 + expect(domNode.textContent).toEqual('HELLO, WORLD.');
378 + },
379 + );
380 +
381 + itRenders(
382 + 'should only invoke the memoized function when the inputs change',
383 + async render => {
384 + function CapitalizedText(props) {
385 + const [text, setText] = useState(props.text);
386 + const [count, setCount] = useState(0);
387 + const capitalizedText = useMemo(
388 + () => {
389 + yieldValue(`Capitalize '${text}'`);
390 + return text.toUpperCase();
391 + },
392 + [text],
393 + );
394 +
395 + yieldValue(count);
396 +
397 + if (count < 3) {
398 + setCount(count + 1);
399 + }
400 +
401 + if (text === 'hello' && count === 2) {
402 + setText('hello, world.');
403 + }
404 + return <Text text={capitalizedText} />;
405 + }
406 +
407 + const domNode = await render(<CapitalizedText text="hello" />);
408 + expect(clearYields()).toEqual([
409 + "Capitalize 'hello'",
410 + 0,
411 + 1,
412 + 2,
413 + // `capitalizedText` only recomputes when the text has changed
414 + "Capitalize 'hello, world.'",
415 + 3,
416 + 'HELLO, WORLD.',
417 + ]);
418 + expect(domNode.tagName).toEqual('SPAN');
419 + expect(domNode.textContent).toEqual('HELLO, WORLD.');
420 + },
421 + );
422 + });
423 +
424 + describe('useRef', () => {
425 + itRenders('basic render', async render => {
426 + function Counter(props) {
427 + const count = useRef(0);
428 + return <span>Count: {count.current}</span>;
429 + }
430 +
431 + const domNode = await render(<Counter />);
432 + expect(domNode.textContent).toEqual('Count: 0');
433 + });
434 +
435 + itRenders(
436 + 'multiple times when updates happen during the render phase',
437 + async render => {
438 + function Counter(props) {
439 + const [count, setCount] = useState(0);
440 + const ref = useRef(count);
441 +
442 + if (count < 3) {
443 + const newCount = count + 1;
444 +
445 + ref.current = newCount;
446 + setCount(newCount);
447 + }
448 +
449 + yieldValue(count);
450 +
451 + return <span>Count: {ref.current}</span>;
452 + }
453 +
454 + const domNode = await render(<Counter />);
455 + expect(clearYields()).toEqual([0, 1, 2, 3]);
456 + expect(domNode.textContent).toEqual('Count: 3');
457 + },
458 + );
459 +
460 + itRenders(
461 + 'always return the same reference through multiple renders',
462 + async render => {
463 + let firstRef = null;
464 + function Counter(props) {
465 + const [count, setCount] = useState(0);
466 + const ref = useRef(count);
467 + if (firstRef === null) {
468 + firstRef = ref;
469 + } else if (firstRef !== ref) {
470 + throw new Error('should never change');
471 + }
472 +
473 + if (count < 3) {
474 + setCount(count + 1);
475 + } else {
476 + firstRef = null;
477 + }
478 +
479 + yieldValue(count);
480 +
481 + return <span>Count: {ref.current}</span>;
482 + }
483 +
484 + const domNode = await render(<Counter />);
485 + expect(clearYields()).toEqual([0, 1, 2, 3]);
486 + expect(domNode.textContent).toEqual('Count: 0');
487 + },
488 + );
489 + });
490 +
491 + describe('useEffect', () => {
492 + itRenders('should ignore effects on the server', async render => {
493 + function Counter(props) {
494 + useEffect(() => {
495 + yieldValue('should not be invoked');
496 + });
497 + return <Text text={'Count: ' + props.count} />;
498 + }
499 + const domNode = await render(<Counter count={0} />);
500 + expect(clearYields()).toEqual(['Count: 0']);
501 + expect(domNode.tagName).toEqual('SPAN');
502 + expect(domNode.textContent).toEqual('Count: 0');
503 + });
504 + });
505 +
506 + describe('useCallback', () => {
507 + itRenders('should ignore callbacks on the server', async render => {
508 + function Counter(props) {
509 + useCallback(() => {
510 + yieldValue('should not be invoked');
511 + });
512 + return <Text text={'Count: ' + props.count} />;
513 + }
514 + const domNode = await render(<Counter count={0} />);
515 + expect(clearYields()).toEqual(['Count: 0']);
516 + expect(domNode.tagName).toEqual('SPAN');
517 + expect(domNode.textContent).toEqual('Count: 0');
518 + });
519 + });
520 +
521 + describe('useAPI', () => {
522 + it('should not be invoked on the server', async () => {
523 + function Counter(props, ref) {
524 + useAPI(ref, () => {
525 + throw new Error('should not be invoked');
526 + });
527 + return <Text text={props.label + ': ' + ref.current} />;
528 + }
529 + Counter = forwardRef(Counter);
530 + const counter = React.createRef();
531 + counter.current = 0;
532 + const domNode = await serverRender(
533 + <Counter label="Count" ref={counter} />,
534 + );
535 + expect(clearYields()).toEqual(['Count: 0']);
536 + expect(domNode.tagName).toEqual('SPAN');
537 + expect(domNode.textContent).toEqual('Count: 0');
538 + });
539 + });
540 +
541 + describe('useMutationEffect', () => {
542 + it('should warn when invoked during render', async () => {
543 + function Counter() {
544 + useMutationEffect(() => {
545 + throw new Error('should not be invoked');
546 + });
547 +
548 + return <Text text="Count: 0" />;
549 + }
550 + const domNode = await serverRender(<Counter />, 1);
551 + expect(clearYields()).toEqual(['Count: 0']);
552 + expect(domNode.tagName).toEqual('SPAN');
553 + expect(domNode.textContent).toEqual('Count: 0');
554 + });
555 + });
556 +
557 + describe('useLayoutEffect', () => {
558 + it('should warn when invoked during render', async () => {
559 + function Counter() {
560 + useLayoutEffect(() => {
561 + throw new Error('should not be invoked');
562 + });
563 +
564 + return <Text text="Count: 0" />;
565 + }
566 + const domNode = await serverRender(<Counter />, 1);
567 + expect(clearYields()).toEqual(['Count: 0']);
568 + expect(domNode.tagName).toEqual('SPAN');
569 + expect(domNode.textContent).toEqual('Count: 0');
570 + });
571 + });
572 +
573 + describe('useContext', () => {
574 + itRenders(
575 + 'can use the same context multiple times in the same function',
576 + async render => {
577 + const Context = React.createContext(
578 + {foo: 0, bar: 0, baz: 0},
579 + (a, b) => {
580 + let result = 0;
581 + if (a.foo !== b.foo) {
582 + result |= 0b001;
583 + }
584 + if (a.bar !== b.bar) {
585 + result |= 0b010;
586 + }
587 + if (a.baz !== b.baz) {
588 + result |= 0b100;
589 + }
590 + return result;
591 + },
592 + );
593 +
594 + function Provider(props) {
595 + return (
596 + <Context.Provider
597 + value={{foo: props.foo, bar: props.bar, baz: props.baz}}>
598 + {props.children}
599 + </Context.Provider>
600 + );
601 + }
602 +
603 + function FooAndBar() {
604 + const {foo} = useContext(Context, 0b001);
605 + const {bar} = useContext(Context, 0b010);
606 + return <Text text={`Foo: ${foo}, Bar: ${bar}`} />;
607 + }
608 +
609 + function Baz() {
610 + const {baz} = useContext(Context, 0b100);
611 + return <Text text={'Baz: ' + baz} />;
612 + }
613 +
614 + class Indirection extends React.Component {
615 + shouldComponentUpdate() {
616 + return false;
617 + }
618 + render() {
619 + return this.props.children;
620 + }
621 + }
622 +
623 + function App(props) {
624 + return (
625 + <div>
626 + <Provider foo={props.foo} bar={props.bar} baz={props.baz}>
627 + <Indirection>
628 + <Indirection>
629 + <FooAndBar />
630 + </Indirection>
631 + <Indirection>
632 + <Baz />
633 + </Indirection>
634 + </Indirection>
635 + </Provider>
636 + </div>
637 + );
638 + }
639 +
640 + const domNode = await render(<App foo={1} bar={3} baz={5} />);
641 + expect(clearYields()).toEqual(['Foo: 1, Bar: 3', 'Baz: 5']);
642 + expect(domNode.childNodes.length).toBe(2);
643 + expect(domNode.firstChild.tagName).toEqual('SPAN');
644 + expect(domNode.firstChild.textContent).toEqual('Foo: 1, Bar: 3');
645 + expect(domNode.lastChild.tagName).toEqual('SPAN');
646 + expect(domNode.lastChild.textContent).toEqual('Baz: 5');
647 + },
648 + );
649 + });
650 +});
packages/react-dom/src/server/ReactPartialRenderer.js
+19 -11
@@ -48,6 +48,11 @@ import {
48 createMarkupForRoot,
49 } from './DOMMarkupOperations';
50 import escapeTextForBrowser from './escapeTextForBrowser';
51 +import {
52 + prepareToUseHooks,
53 + finishHooks,
54 + Dispatcher,
55 +} from './ReactPartialRendererHooks';
56 import {
57 Namespaces,
58 getIntrinsicNamespace,
@@ -87,15 +92,6 @@ let pushCurrentDebugStack = (stack: Array<Frame>) => {};
92 let pushElementToDebugStack = (element: ReactElement) => {};
93 let popCurrentDebugStack = () => {};
94
90 -let Dispatcher = {
91 - readContext<T>(
92 - context: ReactContext<T>,
93 - observedBits: void | number | boolean,
94 - ): T {
95 - return context._currentValue;
96 - },
97 -};
98 -
95 if (__DEV__) {
96 ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
97
@@ -573,7 +569,11 @@ function resolve(
569 }
570 }
571 }
572 + const componentIdentity = {};
573 + prepareToUseHooks(componentIdentity);
574 inst = Component(element.props, publicContext, updater);
575 + inst = finishHooks(Component, element.props, inst, publicContext);
576 +
577 if (inst == null || inst.render == null) {
578 child = inst;
579 validateRenderResult(child, Component);
@@ -985,9 +985,17 @@ class ReactDOMServerRenderer {
985 switch (elementType.$$typeof) {
986 case REACT_FORWARD_REF_TYPE: {
987 const element: ReactElement = ((nextChild: any): ReactElement);
988 - const nextChildren = toArray(
989 - elementType.render(element.props, element.ref),
988 + let nextChildren;
989 + const componentIdentity = {};
990 + prepareToUseHooks(componentIdentity);
991 + nextChildren = elementType.render(element.props, element.ref);
992 + nextChildren = finishHooks(
993 + elementType.render,
994 + element.props,
995 + nextChildren,
996 + element.ref,
997 );
998 + nextChildren = toArray(nextChildren);
999 const frame: Frame = {
1000 type: null,
1001 domNamespace: parentNamespace,
packages/react-dom/src/server/ReactPartialRendererHooks.js new
+366
@@ -0,0 +1,366 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +import type {ReactContext} from 'shared/ReactTypes';
10 +
11 +import invariant from 'shared/invariant';
12 +import warning from 'shared/warning';
13 +
14 +type BasicStateAction<S> = S | (S => S);
15 +type MaybeCallback<S> = void | null | (S => mixed);
16 +type Dispatch<S, A> = (A, MaybeCallback<S>) => void;
17 +
18 +type Update<S, A> = {
19 + action: A,
20 + next: Update<S, A> | null,
21 +};
22 +
23 +type UpdateQueue<S, A> = {
24 + last: Update<S, A> | null,
25 + dispatch: any,
26 +};
27 +
28 +type Hook = {
29 + memoizedState: any,
30 + queue: UpdateQueue<any, any> | null,
31 + next: Hook | null,
32 +};
33 +
34 +let currentlyRenderingComponent: Object | null = null;
35 +let firstWorkInProgressHook: Hook | null = null;
36 +let workInProgressHook: Hook | null = null;
37 +// Whether the work-in-progress hook is a re-rendered hook
38 +let isReRender: boolean = false;
39 +// Whether an update was scheduled during the currently executing render pass.
40 +let didScheduleRenderPhaseUpdate: boolean = false;
41 +// Lazily created map of render-phase updates
42 +let renderPhaseUpdates: Map<
43 + UpdateQueue<any, any>,
44 + Update<any, any>,
45 +> | null = null;
46 +// Counter to prevent infinite loops.
47 +let numberOfReRenders: number = 0;
48 +const RE_RENDER_LIMIT = 25;
49 +
50 +function resolveCurrentlyRenderingComponent(): Object {
51 + invariant(
52 + currentlyRenderingComponent !== null,
53 + 'Hooks can only be called inside the body of a functional component.',
54 + );
55 + return currentlyRenderingComponent;
56 +}
57 +
58 +function createHook(): Hook {
59 + return {
60 + memoizedState: null,
61 + queue: null,
62 + next: null,
63 + };
64 +}
65 +
66 +function createWorkInProgressHook(): Hook {
67 + if (workInProgressHook === null) {
68 + // This is the first hook in the list
69 + if (firstWorkInProgressHook === null) {
70 + isReRender = false;
71 + firstWorkInProgressHook = workInProgressHook = createHook();
72 + } else {
73 + // There's already a work-in-progress. Reuse it.
74 + isReRender = true;
75 + workInProgressHook = firstWorkInProgressHook;
76 + }
77 + } else {
78 + if (workInProgressHook.next === null) {
79 + isReRender = false;
80 + // Append to the end of the list
81 + workInProgressHook = workInProgressHook.next = createHook();
82 + } else {
83 + // There's already a work-in-progress. Reuse it.
84 + isReRender = true;
85 + workInProgressHook = workInProgressHook.next;
86 + }
87 + }
88 + return workInProgressHook;
89 +}
90 +
91 +export function prepareToUseHooks(componentIdentity: Object): void {
92 + currentlyRenderingComponent = componentIdentity;
93 +
94 + // The following should have already been reset
95 + // didScheduleRenderPhaseUpdate = false;
96 + // firstWorkInProgressHook = null;
97 + // numberOfReRenders = 0;
98 + // renderPhaseUpdates = null;
99 + // workInProgressHook = null;
100 +}
101 +
102 +export function finishHooks(
103 + Component: any,
104 + props: any,
105 + children: any,
106 + refOrContext: any,
107 +): any {
108 + // This must be called after every functional component to prevent hooks from
109 + // being used in classes.
110 +
111 + while (didScheduleRenderPhaseUpdate) {
112 + // Updates were scheduled during the render phase. They are stored in
113 + // the `renderPhaseUpdates` map. Call the component again, reusing the
114 + // work-in-progress hooks and applying the additional updates on top. Keep
115 + // restarting until no more updates are scheduled.
116 + didScheduleRenderPhaseUpdate = false;
117 + numberOfReRenders += 1;
118 +
119 + // Start over from the beginning of the list
120 + workInProgressHook = null;
121 +
122 + children = Component(props, refOrContext);
123 + }
124 + currentlyRenderingComponent = null;
125 + firstWorkInProgressHook = null;
126 + numberOfReRenders = 0;
127 + renderPhaseUpdates = null;
128 + workInProgressHook = null;
129 +
130 + // These were reset above
131 + // currentlyRenderingComponent = null;
132 + // didScheduleRenderPhaseUpdate = false;
133 + // firstWorkInProgressHook = null;
134 + // numberOfReRenders = 0;
135 + // renderPhaseUpdates = null;
136 + // workInProgressHook = null;
137 +
138 + return children;
139 +}
140 +
141 +function useContext<T>(
142 + context: ReactContext<T>,
143 + observedBits: void | number | boolean,
144 +): T {
145 + return context._currentValue;
146 +}
147 +
148 +function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
149 + return typeof action === 'function' ? action(state) : action;
150 +}
151 +
152 +export function useState<S>(
153 + initialState: S | (() => S),
154 +): [S, Dispatch<S, BasicStateAction<S>>] {
155 + return useReducer(
156 + basicStateReducer,
157 + // useReducer has a special case to support lazy useState initializers
158 + (initialState: any),
159 + );
160 +}
161 +
162 +export function useReducer<S, A>(
163 + reducer: (S, A) => S,
164 + initialState: S,
165 + initialAction: A | void | null,
166 +): [S, Dispatch<S, A>] {
167 + currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
168 + workInProgressHook = createWorkInProgressHook();
169 + if (isReRender) {
170 + // This is a re-render. Apply the new render phase updates to the previous
171 + // current hook.
172 + const queue: UpdateQueue<S, A> = (workInProgressHook.queue: any);
173 + const dispatch: Dispatch<S, A> = (queue.dispatch: any);
174 + if (renderPhaseUpdates !== null) {
175 + // Render phase updates are stored in a map of queue -> linked list
176 + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
177 + if (firstRenderPhaseUpdate !== undefined) {
178 + renderPhaseUpdates.delete(queue);
179 + let newState = workInProgressHook.memoizedState;
180 + let update = firstRenderPhaseUpdate;
181 + do {
182 + // Process this render phase update. We don't have to check the
183 + // priority because it will always be the same as the current
184 + // render's.
185 + const action = update.action;
186 + newState = reducer(newState, action);
187 + update = update.next;
188 + } while (update !== null);
189 +
190 + workInProgressHook.memoizedState = newState;
191 +
192 + return [newState, dispatch];
193 + }
194 + }
195 + return [workInProgressHook.memoizedState, dispatch];
196 + } else {
197 + if (reducer === basicStateReducer) {
198 + // Special case for `useState`.
199 + if (typeof initialState === 'function') {
200 + initialState = initialState();
201 + }
202 + } else if (initialAction !== undefined && initialAction !== null) {
203 + initialState = reducer(initialState, initialAction);
204 + }
205 + workInProgressHook.memoizedState = initialState;
206 + const queue: UpdateQueue<S, A> = (workInProgressHook.queue = {
207 + last: null,
208 + dispatch: null,
209 + });
210 + const dispatch: Dispatch<S, A> = (queue.dispatch = (dispatchAction.bind(
211 + null,
212 + currentlyRenderingComponent,
213 + queue,
214 + ): any));
215 + return [workInProgressHook.memoizedState, dispatch];
216 + }
217 +}
218 +
219 +function useMemo<T>(
220 + nextCreate: () => T,
221 + inputs: Array<mixed> | void | null,
222 +): T {
223 + currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
224 + workInProgressHook = createWorkInProgressHook();
225 +
226 + const nextInputs =
227 + inputs !== undefined && inputs !== null ? inputs : [nextCreate];
228 +
229 + if (
230 + workInProgressHook !== null &&
231 + workInProgressHook.memoizedState !== null
232 + ) {
233 + const prevState = workInProgressHook.memoizedState;
234 + const prevInputs = prevState[1];
235 + if (inputsAreEqual(nextInputs, prevInputs)) {
236 + return prevState[0];
237 + }
238 + }
239 +
240 + const nextValue = nextCreate();
241 + workInProgressHook.memoizedState = [nextValue, nextInputs];
242 + return nextValue;
243 +}
244 +
245 +function useRef<T>(initialValue: T): {current: T} {
246 + currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
247 + workInProgressHook = createWorkInProgressHook();
248 + const previousRef = workInProgressHook.memoizedState;
249 + if (previousRef === null) {
250 + const ref = {current: initialValue};
251 + if (__DEV__) {
252 + Object.seal(ref);
253 + }
254 + workInProgressHook.memoizedState = ref;
255 + return ref;
256 + } else {
257 + return previousRef;
258 + }
259 +}
260 +
261 +function useMutationEffect(
262 + create: () => mixed,
263 + inputs: Array<mixed> | void | null,
264 +) {
265 + warning(
266 + false,
267 + 'useMutationEffect does nothing on the server, because its effect cannot ' +
268 + "be encoded into the server renderer's output format. This will lead " +
269 + 'to a mismatch between the initial, non-hydrated UI and the intended ' +
270 + 'UI. To avoid this, useMutationEffect should only be used in ' +
271 + 'components that render exclusively on the client.',
272 + );
273 +}
274 +
275 +export function useLayoutEffect(
276 + create: () => mixed,
277 + inputs: Array<mixed> | void | null,
278 +) {
279 + warning(
280 + false,
281 + 'useLayoutEffect does nothing on the server, because its effect cannot ' +
282 + "be encoded into the server renderer's output format. This will lead " +
283 + 'to a mismatch between the initial, non-hydrated UI and the intended ' +
284 + 'UI. To avoid this, useLayoutEffect should only be used in ' +
285 + 'components that render exclusively on the client.',
286 + );
287 +}
288 +
289 +function dispatchAction<S, A>(
290 + componentIdentity: Object,
291 + queue: UpdateQueue<S, A>,
292 + action: A,
293 +) {
294 + invariant(
295 + numberOfReRenders < RE_RENDER_LIMIT,
296 + 'Too many re-renders. React limits the number of renders to prevent ' +
297 + 'an infinite loop.',
298 + );
299 +
300 + if (componentIdentity === currentlyRenderingComponent) {
301 + // This is a render phase update. Stash it in a lazily-created map of
302 + // queue -> linked list of updates. After this render pass, we'll restart
303 + // and apply the stashed updates on top of the work-in-progress hook.
304 + didScheduleRenderPhaseUpdate = true;
305 + const update: Update<S, A> = {
306 + action,
307 + next: null,
308 + };
309 + if (renderPhaseUpdates === null) {
310 + renderPhaseUpdates = new Map();
311 + }
312 + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
313 + if (firstRenderPhaseUpdate === undefined) {
314 + renderPhaseUpdates.set(queue, update);
315 + } else {
316 + // Append the update to the end of the list.
317 + let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
318 + while (lastRenderPhaseUpdate.next !== null) {
319 + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
320 + }
321 + lastRenderPhaseUpdate.next = update;
322 + }
323 + } else {
324 + // This means an update has happened after the functional component has
325 + // returned. On the server this is a no-op. In React Fiber, the update
326 + // would be scheduled for a future render.
327 + }
328 +}
329 +
330 +function inputsAreEqual(arr1, arr2) {
331 + // Don't bother comparing lengths because these arrays are always
332 + // passed inline.
333 + for (let i = 0; i < arr1.length; i++) {
334 + // Inlined Object.is polyfill.
335 + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
336 + const val1 = arr1[i];
337 + const val2 = arr2[i];
338 + if (
339 + (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) ||
340 + (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare
341 + ) {
342 + continue;
343 + }
344 + return false;
345 + }
346 + return true;
347 +}
348 +
349 +function noop(): void {}
350 +
351 +export const Dispatcher = {
352 + readContext: useContext,
353 + useContext,
354 + useMemo,
355 + useReducer,
356 + useRef,
357 + useState,
358 + useMutationEffect,
359 + useLayoutEffect,
360 + // useAPI is not run in the server environment
361 + useAPI: noop,
362 + // Callbacks are not run in the server environment.
363 + useCallback: noop,
364 + // Effects are not run in the server environment.
365 + useEffect: noop,
366 +};
packages/react-reconciler/src/ReactFiberHooks.js
+63 -61
@@ -347,47 +347,48 @@ export function useReducer<S, A>(
347 ): [S, Dispatch<S, A>] {
348 currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
349 workInProgressHook = createWorkInProgressHook();
350 - if (isReRender) {
351 - // This is a re-render. Apply the new render phase updates to the previous
352 - // work-in-progress hook.
353 - const queue: UpdateQueue<S, A> = (workInProgressHook.queue: any);
354 - const dispatch: Dispatch<S, A> = (queue.dispatch: any);
355 - if (renderPhaseUpdates !== null) {
356 - // Render phase updates are stored in a map of queue -> linked list
357 - const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
358 - if (firstRenderPhaseUpdate !== undefined) {
359 - renderPhaseUpdates.delete(queue);
360 - let newState = workInProgressHook.memoizedState;
361 - let update = firstRenderPhaseUpdate;
362 - do {
363 - // Process this render phase update. We don't have to check the
364 - // priority because it will always be the same as the current
365 - // render's.
366 - const action = update.action;
367 - newState = reducer(newState, action);
368 - const callback = update.callback;
369 - if (callback !== null) {
370 - pushCallback(currentlyRenderingFiber, update);
350 + let queue: UpdateQueue<S, A> | null = (workInProgressHook.queue: any);
351 + if (queue !== null) {
352 + // Already have a queue, so this is an update.
353 + if (isReRender) {
354 + // This is a re-render. Apply the new render phase updates to the previous
355 + // work-in-progress hook.
356 + const dispatch: Dispatch<S, A> = (queue.dispatch: any);
357 + if (renderPhaseUpdates !== null) {
358 + // Render phase updates are stored in a map of queue -> linked list
359 + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
360 + if (firstRenderPhaseUpdate !== undefined) {
361 + renderPhaseUpdates.delete(queue);
362 + let newState = workInProgressHook.memoizedState;
363 + let update = firstRenderPhaseUpdate;
364 + do {
365 + // Process this render phase update. We don't have to check the
366 + // priority because it will always be the same as the current
367 + // render's.
368 + const action = update.action;
369 + newState = reducer(newState, action);
370 + const callback = update.callback;
371 + if (callback !== null) {
372 + pushCallback(currentlyRenderingFiber, update);
373 + }
374 + update = update.next;
375 + } while (update !== null);
376 +
377 + workInProgressHook.memoizedState = newState;
378 +
379 + // Don't persist the state accumlated from the render phase updates to
380 + // the base state unless the queue is empty.
381 + // TODO: Not sure if this is the desired semantics, but it's what we
382 + // do for gDSFP. I can't remember why.
383 + if (workInProgressHook.baseUpdate === queue.last) {
384 + workInProgressHook.baseState = newState;
385 }
372 - update = update.next;
373 - } while (update !== null);
374 -
375 - workInProgressHook.memoizedState = newState;
386
377 - // Don't persist the state accumlated from the render phase updates to
378 - // the base state unless the queue is empty.
379 - // TODO: Not sure if this is the desired semantics, but it's what we
380 - // do for gDSFP. I can't remember why.
381 - if (workInProgressHook.baseUpdate === queue.last) {
382 - workInProgressHook.baseState = newState;
387 + return [newState, dispatch];
388 }
384 -
385 - return [newState, dispatch];
389 }
390 + return [workInProgressHook.memoizedState, dispatch];
391 }
388 - return [workInProgressHook.memoizedState, dispatch];
389 - } else if (currentHook !== null) {
390 - const queue: UpdateQueue<S, A> = (workInProgressHook.queue: any);
392
393 // The last update in the entire queue
394 const last = queue.last;
@@ -457,27 +458,28 @@ export function useReducer<S, A>(
458
459 const dispatch: Dispatch<S, A> = (queue.dispatch: any);
460 return [workInProgressHook.memoizedState, dispatch];
460 - } else {
461 - if (reducer === basicStateReducer) {
462 - // Special case for `useState`.
463 - if (typeof initialState === 'function') {
464 - initialState = initialState();
465 - }
466 - } else if (initialAction !== undefined && initialAction !== null) {
467 - initialState = reducer(initialState, initialAction);
461 + }
462 +
463 + // There's no existing queue, so this is the initial render.
464 + if (reducer === basicStateReducer) {
465 + // Special case for `useState`.
466 + if (typeof initialState === 'function') {
467 + initialState = initialState();
468 }
469 - workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
470 - const queue: UpdateQueue<S, A> = (workInProgressHook.queue = {
471 - last: null,
472 - dispatch: null,
473 - });
474 - const dispatch: Dispatch<S, A> = (queue.dispatch = (dispatchAction.bind(
475 - null,
476 - currentlyRenderingFiber,
477 - queue,
478 - ): any));
479 - return [workInProgressHook.memoizedState, dispatch];
469 + } else if (initialAction !== undefined && initialAction !== null) {
470 + initialState = reducer(initialState, initialAction);
471 }
472 + workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
473 + queue = workInProgressHook.queue = {
474 + last: null,
475 + dispatch: null,
476 + };
477 + const dispatch: Dispatch<S, A> = (queue.dispatch = (dispatchAction.bind(
478 + null,
479 + currentlyRenderingFiber,
480 + queue,
481 + ): any));
482 + return [workInProgressHook.memoizedState, dispatch];
483 }
484
485 function pushCallback(workInProgress: Fiber, update: Update<any, any>): void {
@@ -525,7 +527,8 @@ export function useRef<T>(initialValue: T): {current: T} {
527 currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
528 workInProgressHook = createWorkInProgressHook();
529 let ref;
528 - if (currentHook === null) {
530 +
531 + if (workInProgressHook.memoizedState === null) {
532 ref = {current: initialValue};
533 if (__DEV__) {
534 Object.seal(ref);
@@ -637,14 +640,13 @@ export function useCallback<T>(
640 const nextInputs =
641 inputs !== undefined && inputs !== null ? inputs : [callback];
642
640 - if (currentHook !== null) {
641 - const prevState = currentHook.memoizedState;
643 + const prevState = workInProgressHook.memoizedState;
644 + if (prevState !== null) {
645 const prevInputs = prevState[1];
646 if (inputsAreEqual(nextInputs, prevInputs)) {
647 return prevState[0];
648 }
649 }
647 -
650 workInProgressHook.memoizedState = [callback, nextInputs];
651 return callback;
652 }
@@ -659,8 +661,8 @@ export function useMemo<T>(
661 const nextInputs =
662 inputs !== undefined && inputs !== null ? inputs : [nextCreate];
663
662 - if (currentHook !== null) {
663 - const prevState = currentHook.memoizedState;
664 + const prevState = workInProgressHook.memoizedState;
665 + if (prevState !== null) {
666 const prevInputs = prevState[1];
667 if (inputsAreEqual(nextInputs, prevInputs)) {
668 return prevState[0];
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+51
@@ -1417,6 +1417,33 @@ describe('ReactHooks', () => {
1417 ReactNoop.render(<LazyCompute compute={computeB} />);
1418 expect(ReactNoop.flush()).toEqual(['compute B', 'B']);
1419 });
1420 +
1421 + it('should not invoke memoized function during re-renders unless inputs change', () => {
1422 + function LazyCompute(props) {
1423 + const computed = useMemo(() => props.compute(props.input), [
1424 + props.input,
1425 + ]);
1426 + const [count, setCount] = useState(0);
1427 + if (count < 3) {
1428 + setCount(count + 1);
1429 + }
1430 + return <Text text={computed} />;
1431 + }
1432 +
1433 + function compute(val) {
1434 + ReactNoop.yield('compute ' + val);
1435 + return val;
1436 + }
1437 +
1438 + ReactNoop.render(<LazyCompute compute={compute} input="A" />);
1439 + expect(ReactNoop.flush()).toEqual(['compute A', 'A']);
1440 +
1441 + ReactNoop.render(<LazyCompute compute={compute} input="A" />);
1442 + expect(ReactNoop.flush()).toEqual(['A']);
1443 +
1444 + ReactNoop.render(<LazyCompute compute={compute} input="B" />);
1445 + expect(ReactNoop.flush()).toEqual(['compute B', 'B']);
1446 + });
1447 });
1448
1449 describe('useRef', () => {
@@ -1476,6 +1503,30 @@ describe('ReactHooks', () => {
1503 jest.advanceTimersByTime(20);
1504 expect(ReactNoop.flush()).toEqual(['ping: 6']);
1505 });
1506 +
1507 + it('should return the same ref during re-renders', () => {
1508 + function Counter() {
1509 + const ref = useRef('val');
1510 + const [count, setCount] = useState(0);
1511 + const [firstRef] = useState(ref);
1512 +
1513 + if (firstRef !== ref) {
1514 + throw new Error('should never change');
1515 + }
1516 +
1517 + if (count < 3) {
1518 + setCount(count + 1);
1519 + }
1520 +
1521 + return <Text text={ref.current} />;
1522 + }
1523 +
1524 + ReactNoop.render(<Counter />);
1525 + expect(ReactNoop.flush()).toEqual(['val']);
1526 +
1527 + ReactNoop.render(<Counter />);
1528 + expect(ReactNoop.flush()).toEqual(['val']);
1529 + });
1530 });
1531
1532 describe('progressive enhancement', () => {