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', () => {