Initial hooks implementation
Includes: - useState - useContext - useEffect - useRef - useReducer - useCallback - useMemo - useAPI
Andrew Clark committed
Sep 5, 2018 at 11:29 UTC
7bee9fbdd49aa5b9365a94b0ddf6db04bc1bf51c
9 files changed
+2075
-31
packages/react-reconciler/src/ReactFiberBeginWork.js
+16
-15
@@ -79,6 +79,7 @@ import {
79
prepareToReadContext,
80
calculateChangedBits,
81
} from './ReactFiberNewContext';
82
+import {prepareToUseHooks, finishHooks, resetHooks} from './ReactFiberHooks';
83
import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
84
import {
85
getMaskedContext,
@@ -193,27 +194,17 @@ function forceUnmountCurrentAndReconcile(
194
function updateForwardRef(
195
current: Fiber | null,
196
workInProgress: Fiber,
196
- type: any,
197
+ Component: any,
198
nextProps: any,
199
renderExpirationTime: ExpirationTime,
200
) {
200
- const render = type.render;
201
+ const render = Component.render;
202
const ref = workInProgress.ref;
202
- if (hasLegacyContextChanged()) {
203
- // Normally we can bail out on props equality but if context has changed
204
- // we don't do the bailout and we have to reuse existing props instead.
205
- } else if (workInProgress.memoizedProps === nextProps) {
206
- const currentRef = current !== null ? current.ref : null;
207
- if (ref === currentRef) {
208
- return bailoutOnAlreadyFinishedWork(
209
- current,
210
- workInProgress,
211
- renderExpirationTime,
212
- );
213
- }
214
- }
203
204
+ // The rest is a fork of updateFunctionComponent
205
let nextChildren;
206
+ prepareToReadContext(workInProgress, renderExpirationTime);
207
+ prepareToUseHooks(current, workInProgress, renderExpirationTime);
208
if (__DEV__) {
209
ReactCurrentOwner.current = workInProgress;
210
ReactCurrentFiber.setCurrentPhase('render');
@@ -222,7 +213,10 @@ function updateForwardRef(
213
} else {
214
nextChildren = render(nextProps, ref);
215
}
216
+ nextChildren = finishHooks(render, nextProps, nextChildren, ref);
217
218
+ // React DevTools reads this flag.
219
+ workInProgress.effectTag |= PerformedWork;
220
reconcileChildren(
221
current,
222
workInProgress,
@@ -406,6 +400,7 @@ function updateFunctionComponent(
400
401
let nextChildren;
402
prepareToReadContext(workInProgress, renderExpirationTime);
403
+ prepareToUseHooks(current, workInProgress, renderExpirationTime);
404
if (__DEV__) {
405
ReactCurrentOwner.current = workInProgress;
406
ReactCurrentFiber.setCurrentPhase('render');
@@ -414,6 +409,7 @@ function updateFunctionComponent(
409
} else {
410
nextChildren = Component(nextProps, context);
411
}
412
+ nextChildren = finishHooks(Component, nextProps, nextChildren, context);
413
414
// React DevTools reads this flag.
415
workInProgress.effectTag |= PerformedWork;
@@ -921,6 +917,7 @@ function mountIndeterminateComponent(
917
const context = getMaskedContext(workInProgress, unmaskedContext);
918
919
prepareToReadContext(workInProgress, renderExpirationTime);
920
+ prepareToUseHooks(null, workInProgress, renderExpirationTime);
921
922
let value;
923
@@ -964,6 +961,9 @@ function mountIndeterminateComponent(
961
// Proceed under the assumption that this is a class instance
962
workInProgress.tag = ClassComponent;
963
964
+ // Throw out any hooks that were used.
965
+ resetHooks();
966
+
967
// Push context providers early to prevent context stack mismatches.
968
// During mounting we don't know the child context yet as the instance doesn't exist.
969
// We will invalidate the child context in finishClassComponent() right after rendering.
@@ -1001,6 +1001,7 @@ function mountIndeterminateComponent(
1001
} else {
1002
// Proceed under the assumption that this is a function component
1003
workInProgress.tag = FunctionComponent;
1004
+ value = finishHooks(Component, props, value, context);
1005
if (__DEV__) {
1006
if (Component) {
1007
warningWithoutStack(
packages/react-reconciler/src/ReactFiberCommitWork.js
+170
@@ -19,12 +19,15 @@ import type {FiberRoot} from './ReactFiberRoot';
19
import type {ExpirationTime} from './ReactFiberExpirationTime';
20
import type {CapturedValue, CapturedError} from './ReactCapturedValue';
21
import type {SuspenseState} from './ReactFiberSuspenseComponent';
22
+import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
23
24
import {
25
enableSchedulerTracing,
26
enableProfilerTimer,
27
} from 'shared/ReactFeatureFlags';
28
import {
29
+ FunctionComponent,
30
+ ForwardRef,
31
ClassComponent,
32
HostRoot,
33
HostComponent,
@@ -180,6 +183,22 @@ function safelyDetachRef(current: Fiber) {
183
}
184
}
185
186
+function safelyCallDestroy(current, destroy) {
187
+ if (__DEV__) {
188
+ invokeGuardedCallback(null, destroy, null);
189
+ if (hasCaughtError()) {
190
+ const error = clearCaughtError();
191
+ captureCommitPhaseError(current, error);
192
+ }
193
+ } else {
194
+ try {
195
+ destroy();
196
+ } catch (error) {
197
+ captureCommitPhaseError(current, error);
198
+ }
199
+ }
200
+}
201
+
202
function commitBeforeMutationLifeCycles(
203
current: Fiber | null,
204
finishedWork: Fiber,
@@ -235,6 +254,28 @@ function commitBeforeMutationLifeCycles(
254
}
255
}
256
257
+function destroyRemainingEffects(firstToDestroy, stopAt) {
258
+ let effect = firstToDestroy;
259
+ do {
260
+ const destroy = effect.value;
261
+ if (destroy !== null) {
262
+ destroy();
263
+ }
264
+ effect = effect.next;
265
+ } while (effect !== stopAt);
266
+}
267
+
268
+function destroyMountedEffects(current) {
269
+ const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
270
+ if (oldUpdateQueue !== null) {
271
+ const oldLastEffect = oldUpdateQueue.lastEffect;
272
+ if (oldLastEffect !== null) {
273
+ const oldFirstEffect = oldLastEffect.next;
274
+ destroyRemainingEffects(oldFirstEffect, oldFirstEffect);
275
+ }
276
+ }
277
+}
278
+
279
function commitLifeCycles(
280
finishedRoot: FiberRoot,
281
current: Fiber | null,
@@ -242,6 +283,116 @@ function commitLifeCycles(
283
committedExpirationTime: ExpirationTime,
284
): void {
285
switch (finishedWork.tag) {
286
+ case FunctionComponent:
287
+ case ForwardRef: {
288
+ const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
289
+ if (updateQueue !== null) {
290
+ // Mount new effects and destroy the old ones by comparing to the
291
+ // current list of effects. This could be a bit simpler if we avoided
292
+ // the need to compare to the previous effect list by transferring the
293
+ // old `destroy` method to the new effect during the render phase.
294
+ // That's how I originally implemented it, but it requires an additional
295
+ // field on the effect object.
296
+ //
297
+ // This supports removing effects from the end of the list. If we adopt
298
+ // the constraint that hooks are append only, that would also save a bit
299
+ // on code size.
300
+ const newLastEffect = updateQueue.lastEffect;
301
+ if (newLastEffect !== null) {
302
+ const newFirstEffect = newLastEffect.next;
303
+ let oldLastEffect = null;
304
+ if (current !== null) {
305
+ const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
306
+ if (oldUpdateQueue !== null) {
307
+ oldLastEffect = oldUpdateQueue.lastEffect;
308
+ }
309
+ }
310
+ if (oldLastEffect !== null) {
311
+ const oldFirstEffect = oldLastEffect.next;
312
+ let newEffect = newFirstEffect;
313
+ let oldEffect = oldFirstEffect;
314
+
315
+ // Before mounting the new effects, unmount all the old ones.
316
+ do {
317
+ if (oldEffect !== null) {
318
+ if (newEffect.inputs !== oldEffect.inputs) {
319
+ const destroy = oldEffect.value;
320
+ if (destroy !== null) {
321
+ destroy();
322
+ }
323
+ }
324
+ oldEffect = oldEffect.next;
325
+ if (oldEffect === oldFirstEffect) {
326
+ oldEffect = null;
327
+ }
328
+ }
329
+ newEffect = newEffect.next;
330
+ } while (newEffect !== newFirstEffect);
331
+
332
+ // Unmount any remaining effects in the old list that do not
333
+ // appear in the new one.
334
+ if (oldEffect !== null) {
335
+ destroyRemainingEffects(oldEffect, oldFirstEffect);
336
+ }
337
+
338
+ // Now loop through the list again to mount the new effects
339
+ oldEffect = oldFirstEffect;
340
+ do {
341
+ const create = newEffect.value;
342
+ if (oldEffect !== null) {
343
+ if (newEffect.inputs !== oldEffect.inputs) {
344
+ const newDestroy = create();
345
+ newEffect.value =
346
+ typeof newDestroy === 'function' ? newDestroy : null;
347
+ } else {
348
+ newEffect.value = oldEffect.value;
349
+ }
350
+ oldEffect = oldEffect.next;
351
+ if (oldEffect === oldFirstEffect) {
352
+ oldEffect = null;
353
+ }
354
+ } else {
355
+ const newDestroy = create();
356
+ newEffect.value =
357
+ typeof newDestroy === 'function' ? newDestroy : null;
358
+ }
359
+ newEffect = newEffect.next;
360
+ } while (newEffect !== newFirstEffect);
361
+ } else {
362
+ let newEffect = newFirstEffect;
363
+ do {
364
+ const create = newEffect.value;
365
+ const newDestroy = create();
366
+ newEffect.value =
367
+ typeof newDestroy === 'function' ? newDestroy : null;
368
+ newEffect = newEffect.next;
369
+ } while (newEffect !== newFirstEffect);
370
+ }
371
+ } else if (current !== null) {
372
+ // There are no effects, which means all current effects must
373
+ // be destroyed
374
+ destroyMountedEffects(current);
375
+ }
376
+
377
+ const callbackList = updateQueue.callbackList;
378
+ if (callbackList !== null) {
379
+ updateQueue.callbackList = null;
380
+ for (let i = 0; i < callbackList.length; i++) {
381
+ const update = callbackList[i];
382
+ // Assume this is non-null, since otherwise it would not be part
383
+ // of the callback list.
384
+ const callback: () => mixed = (update.callback: any);
385
+ update.callback = null;
386
+ callback();
387
+ }
388
+ }
389
+ } else if (current !== null) {
390
+ // There are no effects, which means all current effects must
391
+ // be destroyed
392
+ destroyMountedEffects(current);
393
+ }
394
+ break;
395
+ }
396
case ClassComponent: {
397
const instance = finishedWork.stateNode;
398
if (finishedWork.effectTag & Update) {
@@ -496,6 +647,25 @@ function commitUnmount(current: Fiber): void {
647
onCommitUnmount(current);
648
649
switch (current.tag) {
650
+ case FunctionComponent:
651
+ case ForwardRef: {
652
+ const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
653
+ if (updateQueue !== null) {
654
+ const lastEffect = updateQueue.lastEffect;
655
+ if (lastEffect !== null) {
656
+ const firstEffect = lastEffect.next;
657
+ let effect = firstEffect;
658
+ do {
659
+ const destroy = effect.value;
660
+ if (destroy !== null) {
661
+ safelyCallDestroy(current, destroy);
662
+ }
663
+ effect = effect.next;
664
+ } while (effect !== firstEffect);
665
+ }
666
+ }
667
+ break;
668
+ }
669
case ClassComponent: {
670
safelyDetachRef(current);
671
const instance = current.stateNode;
packages/react-reconciler/src/ReactFiberDispatcher.js
+16
@@ -8,7 +8,23 @@
8
*/
9
10
import {readContext} from './ReactFiberNewContext';
11
+import {
12
+ useState,
13
+ useReducer,
14
+ useEffect,
15
+ useCallback,
16
+ useMemo,
17
+ useRef,
18
+ useAPI,
19
+} from './ReactFiberHooks';
20
21
export const Dispatcher = {
22
readContext,
23
+ useState,
24
+ useReducer,
25
+ useEffect,
26
+ useCallback,
27
+ useMemo,
28
+ useRef,
29
+ useAPI,
30
};
packages/react-reconciler/src/ReactFiberHooks.js
new
+691
@@ -0,0 +1,691 @@
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 direcreatey of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {Fiber} from './ReactFiber';
11
+import type {ExpirationTime} from './ReactFiberExpirationTime';
12
+
13
+import {NoWork} from './ReactFiberExpirationTime';
14
+import {Callback as CallbackEffect} from 'shared/ReactSideEffectTags';
15
+import {
16
+ scheduleWork,
17
+ computeExpirationForFiber,
18
+ requestCurrentTime,
19
+} from './ReactFiberScheduler';
20
+
21
+import invariant from 'shared/invariant';
22
+
23
+type Update<S, A> = {
24
+ expirationTime: ExpirationTime,
25
+ action: A,
26
+ callback: null | (S => mixed),
27
+ next: Update<S, A> | null,
28
+};
29
+
30
+type UpdateQueue<S, A> = {
31
+ last: Update<S, A> | null,
32
+ dispatch: any,
33
+};
34
+
35
+type Hook = {
36
+ memoizedState: any,
37
+
38
+ baseState: any,
39
+ baseUpdate: Update<any, any> | null,
40
+ queue: UpdateQueue<any, any> | null,
41
+
42
+ next: Hook | null,
43
+};
44
+
45
+type Effect = {
46
+ // For an unmounted effect, this points to the effect constructor. Once it's
47
+ // mounted, it points to a destroy function (or null). I've opted to reuse
48
+ // the same field to save memory.
49
+ value: any,
50
+ inputs: Array<mixed>,
51
+ next: Effect,
52
+};
53
+
54
+export type FunctionComponentUpdateQueue = {
55
+ callbackList: Array<Update<any, any>> | null,
56
+ lastEffect: Effect | null,
57
+};
58
+
59
+type BasicStateAction<S> = S | (S => S);
60
+
61
+type MaybeCallback<S> = void | null | (S => mixed);
62
+
63
+type Dispatch<S, A> = (A, MaybeCallback<S>) => void;
64
+
65
+// These are set right before calling the component.
66
+let renderExpirationTime: ExpirationTime = NoWork;
67
+// The work-in-progress fiber. I've named it differently to distinguish it from
68
+// the work-in-progress hook.
69
+let currentlyRenderingFiber: Fiber | null = null;
70
+
71
+// Hooks are stored as a linked list on the fiber's memoizedState field. The
72
+// current hook list is the list that belongs to the current fiber. The
73
+// work-in-progress hook list is a new list that will be added to the
74
+// work-in-progress fiber.
75
+let firstCurrentHook: Hook | null = null;
76
+let currentHook: Hook | null = null;
77
+let firstWorkInProgressHook: Hook | null = null;
78
+let workInProgressHook: Hook | null = null;
79
+
80
+let remainingExpirationTime: ExpirationTime = NoWork;
81
+let componentUpdateQueue: FunctionComponentUpdateQueue | null = null;
82
+
83
+// Updates scheduled during render will trigger an immediate re-render at the
84
+// end of the current pass. We can't store these updates on the normal queue,
85
+// because if the work is aborted, they should be discarded. Because this is
86
+// a relatively rare case, we also don't want to add an additional field to
87
+// either the hook or queue object types. So we store them in a lazily create
88
+// map of queue -> render-phase updates, which are discarded once the component
89
+// completes without re-rendering.
90
+
91
+// Whether the work-in-progress hook is a re-rendered hook
92
+let isReRender: boolean = false;
93
+// Whether an update was scheduled during the currently executing render pass.
94
+let didScheduleRenderPhaseUpdate: boolean = false;
95
+// Lazily created map of render-phase updates
96
+let renderPhaseUpdates: Map<
97
+ UpdateQueue<any, any>,
98
+ Update<any, any>,
99
+> | null = null;
100
+// Counter to prevent infinite loops.
101
+let numberOfReRenders: number = 0;
102
+const RE_RENDER_LIMIT = 25;
103
+
104
+function resolveCurrentlyRenderingFiber(): Fiber {
105
+ invariant(
106
+ currentlyRenderingFiber !== null,
107
+ 'Hooks can only be called inside the body of a functional component.',
108
+ );
109
+ return currentlyRenderingFiber;
110
+}
111
+
112
+export function prepareToUseHooks(
113
+ current: Fiber | null,
114
+ workInProgress: Fiber,
115
+ nextRenderExpirationTime: ExpirationTime,
116
+): void {
117
+ renderExpirationTime = nextRenderExpirationTime;
118
+ currentlyRenderingFiber = workInProgress;
119
+ firstCurrentHook = current !== null ? current.memoizedState : null;
120
+
121
+ // The following should have already been reset
122
+ // currentHook = null;
123
+ // workInProgressHook = null;
124
+
125
+ // remainingExpirationTime = NoWork;
126
+ // componentUpdateQueue = null;
127
+
128
+ // isReRender = false;
129
+ // didScheduleRenderPhaseUpdate = false;
130
+ // renderPhaseUpdates = null;
131
+ // numberOfReRenders = 0;
132
+}
133
+
134
+export function finishHooks(
135
+ Component: any,
136
+ props: any,
137
+ children: any,
138
+ refOrContext: any,
139
+): any {
140
+ // This must be called after every functional component to prevent hooks from
141
+ // being used in classes.
142
+
143
+ while (didScheduleRenderPhaseUpdate) {
144
+ // Updates were scheduled during the render phase. They are stored in
145
+ // the `renderPhaseUpdates` map. Call the component again, reusing the
146
+ // work-in-progress hooks and applying the additional updates on top. Keep
147
+ // restarting until no more updates are scheduled.
148
+ didScheduleRenderPhaseUpdate = false;
149
+ numberOfReRenders += 1;
150
+
151
+ // Start over from the beginning of the list
152
+ currentHook = null;
153
+ workInProgressHook = null;
154
+ componentUpdateQueue = null;
155
+
156
+ children = Component(props, refOrContext);
157
+ }
158
+ renderPhaseUpdates = null;
159
+ numberOfReRenders = 0;
160
+
161
+ const renderedWork: Fiber = (currentlyRenderingFiber: any);
162
+
163
+ renderedWork.memoizedState = firstWorkInProgressHook;
164
+ renderedWork.expirationTime = remainingExpirationTime;
165
+ if (componentUpdateQueue !== null) {
166
+ renderedWork.updateQueue = (componentUpdateQueue: any);
167
+ }
168
+
169
+ renderExpirationTime = NoWork;
170
+ currentlyRenderingFiber = null;
171
+
172
+ firstCurrentHook = null;
173
+ currentHook = null;
174
+ firstWorkInProgressHook = null;
175
+ workInProgressHook = null;
176
+
177
+ remainingExpirationTime = NoWork;
178
+ componentUpdateQueue = null;
179
+
180
+ // Always set during createWorkInProgress
181
+ // isReRender = false;
182
+
183
+ // These were reset above
184
+ // didScheduleRenderPhaseUpdate = false;
185
+ // renderPhaseUpdates = null;
186
+ // numberOfReRenders = 0;
187
+
188
+ return children;
189
+}
190
+
191
+export function resetHooks(): void {
192
+ // This is called instead of `finishHooks` if the component throws. It's also
193
+ // called inside mountIndeterminateComponent if we determine the component
194
+ // is a module-style component.
195
+ renderExpirationTime = NoWork;
196
+ currentlyRenderingFiber = null;
197
+
198
+ firstCurrentHook = null;
199
+ currentHook = null;
200
+ firstWorkInProgressHook = null;
201
+ workInProgressHook = null;
202
+
203
+ remainingExpirationTime = NoWork;
204
+ componentUpdateQueue = null;
205
+
206
+ // Always set during createWorkInProgress
207
+ // isReRender = false;
208
+
209
+ didScheduleRenderPhaseUpdate = false;
210
+ renderPhaseUpdates = null;
211
+ numberOfReRenders = 0;
212
+}
213
+
214
+function createHook(): Hook {
215
+ return {
216
+ memoizedState: null,
217
+
218
+ baseState: null,
219
+ queue: null,
220
+ baseUpdate: null,
221
+
222
+ next: null,
223
+ };
224
+}
225
+
226
+function cloneHook(hook: Hook): Hook {
227
+ return {
228
+ memoizedState: hook.memoizedState,
229
+
230
+ baseState: hook.memoizedState,
231
+ queue: hook.queue,
232
+ baseUpdate: hook.baseUpdate,
233
+
234
+ next: null,
235
+ };
236
+}
237
+
238
+function createWorkInProgressHook(): Hook {
239
+ if (workInProgressHook === null) {
240
+ // This is the first hook in the list
241
+ if (firstWorkInProgressHook === null) {
242
+ isReRender = false;
243
+ currentHook = firstCurrentHook;
244
+ if (currentHook === null) {
245
+ // This is a newly mounted hook
246
+ workInProgressHook = createHook();
247
+ } else {
248
+ // Clone the current hook.
249
+ workInProgressHook = cloneHook(currentHook);
250
+ }
251
+ firstWorkInProgressHook = workInProgressHook;
252
+ } else {
253
+ // There's already a work-in-progress. Reuse it.
254
+ isReRender = true;
255
+ currentHook = firstCurrentHook;
256
+ workInProgressHook = firstWorkInProgressHook;
257
+ }
258
+ } else {
259
+ if (workInProgressHook.next === null) {
260
+ isReRender = false;
261
+ let hook;
262
+ if (currentHook === null) {
263
+ // This is a newly mounted hook
264
+ hook = createHook();
265
+ } else {
266
+ currentHook = currentHook.next;
267
+ if (currentHook === null) {
268
+ // This is a newly mounted hook
269
+ hook = createHook();
270
+ } else {
271
+ // Clone the current hook.
272
+ hook = cloneHook(currentHook);
273
+ }
274
+ }
275
+ // Append to the end of the list
276
+ workInProgressHook = workInProgressHook.next = hook;
277
+ } else {
278
+ // There's already a work-in-progress. Reuse it.
279
+ isReRender = true;
280
+ workInProgressHook = workInProgressHook.next;
281
+ currentHook = currentHook !== null ? currentHook.next : null;
282
+ }
283
+ }
284
+ return workInProgressHook;
285
+}
286
+
287
+function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
288
+ return {
289
+ callbackList: null,
290
+ lastEffect: null,
291
+ };
292
+}
293
+
294
+function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
295
+ return typeof action === 'function' ? action(state) : action;
296
+}
297
+
298
+export function useState<S>(
299
+ initialState: S | (() => S),
300
+): [S, Dispatch<S, BasicStateAction<S>>] {
301
+ return useReducer(
302
+ basicStateReducer,
303
+ // useReducer has a special case to support lazy useState initializers
304
+ (initialState: any),
305
+ );
306
+}
307
+
308
+export function useReducer<S, A>(
309
+ reducer: (S, A) => S,
310
+ initialState: S,
311
+ initialAction: A | void | null,
312
+): [S, Dispatch<S, A>] {
313
+ currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
314
+ workInProgressHook = createWorkInProgressHook();
315
+ if (isReRender) {
316
+ // This is a re-render. Apply the new render phase updates to the previous
317
+ // work-in-progress hook.
318
+ const queue: UpdateQueue<S, A> = (workInProgressHook.queue: any);
319
+ const dispatch: Dispatch<S, A> = (queue.dispatch: any);
320
+ if (renderPhaseUpdates !== null) {
321
+ // Render phase updates are stored in a map of queue -> linked list
322
+ const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
323
+ if (firstRenderPhaseUpdate !== undefined) {
324
+ renderPhaseUpdates.delete(queue);
325
+ let newState = workInProgressHook.memoizedState;
326
+ let update = firstRenderPhaseUpdate;
327
+ do {
328
+ // Process this render phase update. We don't have to check the
329
+ // priority because it will always be the same as the current
330
+ // render's.
331
+ const action = update.action;
332
+ newState = reducer(newState, action);
333
+ const callback = update.callback;
334
+ if (callback !== null) {
335
+ pushCallback(currentlyRenderingFiber, update);
336
+ }
337
+ update = update.next;
338
+ } while (update !== null);
339
+
340
+ workInProgressHook.memoizedState = newState;
341
+
342
+ // Don't persist the state accumlated from the render phase updates to
343
+ // the base state unless the queue is empty.
344
+ // TODO: Not sure if this is the desired semantics, but it's what we
345
+ // do for gDSFP. I can't remember why.
346
+ if (workInProgressHook.baseUpdate === queue.last) {
347
+ workInProgressHook.baseState = newState;
348
+ }
349
+
350
+ return [newState, dispatch];
351
+ }
352
+ }
353
+ return [workInProgressHook.memoizedState, dispatch];
354
+ } else if (currentHook !== null) {
355
+ const queue: UpdateQueue<S, A> = (workInProgressHook.queue: any);
356
+
357
+ // The last update in the entire queue
358
+ const last = queue.last;
359
+ // The last update that is part of the base state.
360
+ const baseUpdate = workInProgressHook.baseUpdate;
361
+
362
+ // Find the first unprocessed update.
363
+ let first;
364
+ if (baseUpdate !== null) {
365
+ if (last !== null) {
366
+ // For the first update, the queue is a circular linked list where
367
+ // `queue.last.next = queue.first`. Once the first update commits, and
368
+ // the `baseUpdate` is no longer empty, we can unravel the list.
369
+ last.next = null;
370
+ }
371
+ first = baseUpdate.next;
372
+ } else {
373
+ first = last !== null ? last.next : null;
374
+ }
375
+ if (first !== null) {
376
+ let newState = workInProgressHook.baseState;
377
+ let newBaseState = null;
378
+ let newBaseUpdate = null;
379
+ let prevUpdate = baseUpdate;
380
+ let update = first;
381
+ let didSkip = false;
382
+ do {
383
+ const updateExpirationTime = update.expirationTime;
384
+ if (updateExpirationTime > renderExpirationTime) {
385
+ // Priority is insufficient. Skip this update. If this is the first
386
+ // skipped update, the previous update/state is the new base
387
+ // update/state.
388
+ if (!didSkip) {
389
+ didSkip = true;
390
+ newBaseUpdate = prevUpdate;
391
+ newBaseState = newState;
392
+ }
393
+ // Update the remaining priority in the queue.
394
+ if (
395
+ remainingExpirationTime === NoWork ||
396
+ updateExpirationTime < remainingExpirationTime
397
+ ) {
398
+ remainingExpirationTime = updateExpirationTime;
399
+ }
400
+ } else {
401
+ // Process this update.
402
+ const action = update.action;
403
+ newState = reducer(newState, action);
404
+ const callback = update.callback;
405
+ if (callback !== null) {
406
+ pushCallback(currentlyRenderingFiber, update);
407
+ }
408
+ }
409
+ prevUpdate = update;
410
+ update = update.next;
411
+ } while (update !== null && update !== first);
412
+
413
+ if (!didSkip) {
414
+ newBaseUpdate = prevUpdate;
415
+ newBaseState = newState;
416
+ }
417
+
418
+ workInProgressHook.memoizedState = newState;
419
+ workInProgressHook.baseUpdate = newBaseUpdate;
420
+ workInProgressHook.baseState = newBaseState;
421
+ }
422
+
423
+ const dispatch: Dispatch<S, A> = (queue.dispatch: any);
424
+ return [workInProgressHook.memoizedState, dispatch];
425
+ } else {
426
+ if (reducer === basicStateReducer) {
427
+ // Special case for `useState`.
428
+ if (typeof initialState === 'function') {
429
+ initialState = initialState();
430
+ }
431
+ } else if (initialAction !== undefined && initialAction !== null) {
432
+ initialState = reducer(initialState, initialAction);
433
+ }
434
+ workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
435
+ const queue: UpdateQueue<S, A> = (workInProgressHook.queue = {
436
+ last: null,
437
+ dispatch: null,
438
+ });
439
+ const dispatch: Dispatch<S, A> = (queue.dispatch = (dispatchAction.bind(
440
+ null,
441
+ currentlyRenderingFiber,
442
+ queue,
443
+ ): any));
444
+ return [workInProgressHook.memoizedState, dispatch];
445
+ }
446
+}
447
+
448
+function pushCallback(workInProgress: Fiber, update: Update<any, any>): void {
449
+ if (componentUpdateQueue === null) {
450
+ componentUpdateQueue = createFunctionComponentUpdateQueue();
451
+ componentUpdateQueue.callbackList = [update];
452
+ } else {
453
+ const callbackList = componentUpdateQueue.callbackList;
454
+ if (callbackList === null) {
455
+ componentUpdateQueue.callbackList = [update];
456
+ } else {
457
+ callbackList.push(update);
458
+ }
459
+ }
460
+ workInProgress.effectTag |= CallbackEffect;
461
+}
462
+
463
+function pushEffect(value, inputs) {
464
+ const effect: Effect = {
465
+ value,
466
+ inputs,
467
+ // Circular
468
+ next: (null: any),
469
+ };
470
+ if (componentUpdateQueue === null) {
471
+ componentUpdateQueue = createFunctionComponentUpdateQueue();
472
+ componentUpdateQueue.lastEffect = effect.next = effect;
473
+ } else {
474
+ const lastEffect = componentUpdateQueue.lastEffect;
475
+ if (lastEffect === null) {
476
+ componentUpdateQueue.lastEffect = effect.next = effect;
477
+ } else {
478
+ const firstEffect = lastEffect.next;
479
+ lastEffect.next = effect;
480
+ effect.next = firstEffect;
481
+ componentUpdateQueue.lastEffect = effect;
482
+ }
483
+ }
484
+ return effect;
485
+}
486
+
487
+export function useRef<T>(initialValue: T): {current: T} {
488
+ currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
489
+ workInProgressHook = createWorkInProgressHook();
490
+ let ref;
491
+ if (currentHook === null) {
492
+ ref = {current: initialValue};
493
+ if (__DEV__) {
494
+ Object.seal(ref);
495
+ }
496
+ workInProgressHook.memoizedState = ref;
497
+ } else {
498
+ ref = workInProgressHook.memoizedState;
499
+ }
500
+ return ref;
501
+}
502
+
503
+export function useEffect(
504
+ create: () => mixed,
505
+ inputs: Array<mixed> | void | null,
506
+): void {
507
+ currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
508
+ workInProgressHook = createWorkInProgressHook();
509
+
510
+ let nextEffect;
511
+ let nextInputs = inputs !== undefined && inputs !== null ? inputs : [create];
512
+ if (currentHook !== null) {
513
+ const prevEffect = currentHook.memoizedState;
514
+ const prevInputs = prevEffect.inputs;
515
+ if (inputsAreEqual(nextInputs, prevInputs)) {
516
+ nextEffect = pushEffect(prevEffect.value, prevInputs);
517
+ } else {
518
+ nextEffect = pushEffect(create, nextInputs);
519
+ }
520
+ } else {
521
+ nextEffect = pushEffect(create, nextInputs);
522
+ }
523
+
524
+ // TODO: If we decide not to support removing hooks from the end of the list,
525
+ // we only need to schedule an effect if the inputs changed.
526
+ currentlyRenderingFiber.effectTag |= CallbackEffect;
527
+ workInProgressHook.memoizedState = nextEffect;
528
+}
529
+
530
+export function useAPI<T>(
531
+ ref: {current: T | null} | ((inst: T | null) => mixed) | null | void,
532
+ create: () => T,
533
+ inputs: Array<mixed> | void | null,
534
+): void {
535
+ // TODO: If inputs are provided, should we skip comparing the ref itself?
536
+ const nextInputs =
537
+ inputs !== null && inputs !== undefined
538
+ ? inputs.concat([ref])
539
+ : [ref, create];
540
+
541
+ // TODO: I've implemented this on top of useEffect because it's almost the
542
+ // same thing, and it would require an equal amount of code. It doesn't seem
543
+ // like a common enough use case to justify the additional size.
544
+ useEffect(() => {
545
+ if (typeof ref === 'function') {
546
+ const refCallback = ref;
547
+ const inst = create();
548
+ refCallback(inst);
549
+ return () => refCallback(null);
550
+ } else if (ref !== null && ref !== undefined) {
551
+ const refObject = ref;
552
+ const inst = create();
553
+ refObject.current = inst;
554
+ return () => {
555
+ refObject.current = null;
556
+ };
557
+ }
558
+ }, nextInputs);
559
+}
560
+
561
+export function useCallback<T>(
562
+ callback: T,
563
+ inputs: Array<mixed> | void | null,
564
+): T {
565
+ currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
566
+ workInProgressHook = createWorkInProgressHook();
567
+
568
+ const nextInputs =
569
+ inputs !== undefined && inputs !== null ? inputs : [callback];
570
+
571
+ if (currentHook !== null) {
572
+ const prevState = currentHook.memoizedState;
573
+ const prevInputs = prevState[1];
574
+ if (inputsAreEqual(nextInputs, prevInputs)) {
575
+ return prevState[0];
576
+ }
577
+ }
578
+
579
+ workInProgressHook.memoizedState = [callback, nextInputs];
580
+ return callback;
581
+}
582
+
583
+export function useMemo<T>(
584
+ nextCreate: () => T,
585
+ inputs: Array<mixed> | void | null,
586
+): T {
587
+ currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
588
+ workInProgressHook = createWorkInProgressHook();
589
+
590
+ const nextInputs =
591
+ inputs !== undefined && inputs !== null ? inputs : [nextCreate];
592
+
593
+ if (currentHook !== null) {
594
+ const prevState = currentHook.memoizedState;
595
+ const prevInputs = prevState[1];
596
+ if (inputsAreEqual(nextInputs, prevInputs)) {
597
+ return prevState[0];
598
+ }
599
+ }
600
+
601
+ const nextValue = nextCreate();
602
+ workInProgressHook.memoizedState = [nextValue, nextInputs];
603
+ return nextValue;
604
+}
605
+
606
+function dispatchAction<S, A>(
607
+ fiber: Fiber,
608
+ queue: UpdateQueue<S, A>,
609
+ action: A,
610
+ callback: void | null | (S => mixed),
611
+) {
612
+ invariant(
613
+ numberOfReRenders < RE_RENDER_LIMIT,
614
+ 'Too many re-renders. React limits the number of renders to prevent ' +
615
+ 'an infinite loop.',
616
+ );
617
+
618
+ const alternate = fiber.alternate;
619
+ if (
620
+ fiber === currentlyRenderingFiber ||
621
+ (alternate !== null && alternate === currentlyRenderingFiber)
622
+ ) {
623
+ // This is a render phase update. Stash it in a lazily-created map of
624
+ // queue -> linked list of updates. After this render pass, we'll restart
625
+ // and apply the stashed updates on top of the work-in-progress hook.
626
+ didScheduleRenderPhaseUpdate = true;
627
+ const update: Update<S, A> = {
628
+ expirationTime: renderExpirationTime,
629
+ action,
630
+ callback: callback !== undefined ? callback : null,
631
+ next: null,
632
+ };
633
+ if (renderPhaseUpdates === null) {
634
+ renderPhaseUpdates = new Map();
635
+ }
636
+ const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
637
+ if (firstRenderPhaseUpdate === undefined) {
638
+ renderPhaseUpdates.set(queue, update);
639
+ } else {
640
+ // Append the update to the end of the list.
641
+ let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
642
+ while (lastRenderPhaseUpdate.next !== null) {
643
+ lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
644
+ }
645
+ lastRenderPhaseUpdate.next = update;
646
+ }
647
+ } else {
648
+ const currentTime = requestCurrentTime();
649
+ const expirationTime = computeExpirationForFiber(currentTime, fiber);
650
+ const update: Update<S, A> = {
651
+ expirationTime,
652
+ action,
653
+ callback: callback !== undefined ? callback : null,
654
+ next: null,
655
+ };
656
+ // Append the update to the end of the list.
657
+ const last = queue.last;
658
+ if (last === null) {
659
+ // This is the first update. Create a circular list.
660
+ update.next = update;
661
+ } else {
662
+ const first = last.next;
663
+ if (first !== null) {
664
+ // Still circular.
665
+ update.next = first;
666
+ }
667
+ last.next = update;
668
+ }
669
+ queue.last = update;
670
+ scheduleWork(fiber, expirationTime);
671
+ }
672
+}
673
+
674
+function inputsAreEqual(arr1, arr2) {
675
+ // Don't bother comparing lengths because these arrays are always
676
+ // passed inline.
677
+ for (let i = 0; i < arr1.length; i++) {
678
+ // Inlined Object.is polyfill.
679
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
680
+ const val1 = arr1[i];
681
+ const val2 = arr2[i];
682
+ if (
683
+ (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) ||
684
+ (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare
685
+ ) {
686
+ continue;
687
+ }
688
+ return false;
689
+ }
690
+ return true;
691
+}
packages/react-reconciler/src/ReactFiberScheduler.js
+5
@@ -122,6 +122,7 @@ import {
122
popContext as popLegacyContext,
123
} from './ReactFiberContext';
124
import {popProvider, resetContextDependences} from './ReactFiberNewContext';
125
+import {resetHooks} from './ReactFiberHooks';
126
import {popHostContext, popHostContainer} from './ReactFiberHostContext';
127
import {
128
recordCommitTime,
@@ -1222,6 +1223,9 @@ function renderRoot(
1223
try {
1224
workLoop(isYieldy);
1225
} catch (thrownValue) {
1226
+ resetContextDependences();
1227
+ resetHooks();
1228
+
1229
if (nextUnitOfWork === null) {
1230
// This is a fatal error.
1231
didFatal = true;
@@ -1284,6 +1288,7 @@ function renderRoot(
1288
isWorking = false;
1289
ReactCurrentOwner.currentDispatcher = null;
1290
resetContextDependences();
1291
+ resetHooks();
1292
1293
// Yield back to main thread.
1294
if (didFatal) {
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
new
+1064
@@ -0,0 +1,1064 @@
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
+ * @jest-environment node
9
+ */
10
+
11
+/* eslint-disable no-func-assign */
12
+
13
+'use strict';
14
+
15
+let React;
16
+let ReactFeatureFlags;
17
+let ReactNoop;
18
+let useState;
19
+let useReducer;
20
+let useEffect;
21
+let useCallback;
22
+let useMemo;
23
+let useRef;
24
+let useAPI;
25
+let forwardRef;
26
+
27
+describe('ReactHooks', () => {
28
+ beforeEach(() => {
29
+ jest.resetModules();
30
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
31
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
32
+ React = require('react');
33
+ ReactNoop = require('react-noop-renderer');
34
+ useState = React.useState;
35
+ useReducer = React.useReducer;
36
+ useEffect = React.useEffect;
37
+ useCallback = React.useCallback;
38
+ useMemo = React.useMemo;
39
+ useRef = React.useRef;
40
+ useAPI = React.useAPI;
41
+ forwardRef = React.forwardRef;
42
+ });
43
+
44
+ function span(prop) {
45
+ return {type: 'span', hidden: false, children: [], prop};
46
+ }
47
+
48
+ function Text(props) {
49
+ ReactNoop.yield(props.text);
50
+ return <span prop={props.text} />;
51
+ }
52
+
53
+ it('resumes after an interruption', () => {
54
+ function Counter(props, ref) {
55
+ const [count, updateCount] = useState(0);
56
+ useAPI(ref, () => ({updateCount}));
57
+ return <Text text={props.label + ': ' + count} />;
58
+ }
59
+ Counter = forwardRef(Counter);
60
+
61
+ // Initial mount
62
+ const counter = React.createRef(null);
63
+ ReactNoop.render(<Counter label="Count" ref={counter} />);
64
+ expect(ReactNoop.flush()).toEqual(['Count: 0']);
65
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
66
+
67
+ // Schedule some updates
68
+ counter.current.updateCount(1);
69
+ counter.current.updateCount(count => count + 10);
70
+ // Partially flush without committing
71
+ ReactNoop.flushThrough(['Count: 11']);
72
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
73
+
74
+ // Interrupt with a high priority update
75
+ ReactNoop.flushSync(() => {
76
+ ReactNoop.render(<Counter label="Total" />);
77
+ });
78
+ expect(ReactNoop.clearYields()).toEqual(['Total: 0']);
79
+
80
+ // Resume rendering
81
+ ReactNoop.flush();
82
+ expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]);
83
+ });
84
+
85
+ it('throws inside class components', () => {
86
+ class BadCounter extends React.Component {
87
+ render() {
88
+ const [count] = useState(0);
89
+ return <Text text={this.props.label + ': ' + count} />;
90
+ }
91
+ }
92
+ ReactNoop.render(<BadCounter />);
93
+
94
+ expect(() => ReactNoop.flush()).toThrow(
95
+ 'Hooks can only be called inside the body of a functional component.',
96
+ );
97
+
98
+ // Confirm that a subsequent hook works properly.
99
+ function GoodCounter(props, ref) {
100
+ const [count] = useState(props.initialCount);
101
+ return <Text text={count} />;
102
+ }
103
+ ReactNoop.render(<GoodCounter initialCount={10} />);
104
+ expect(ReactNoop.flush()).toEqual([10]);
105
+ });
106
+
107
+ it('throws inside module-style components', () => {
108
+ function Counter() {
109
+ return {
110
+ render() {
111
+ const [count] = useState(0);
112
+ return <Text text={this.props.label + ': ' + count} />;
113
+ },
114
+ };
115
+ }
116
+ ReactNoop.render(<Counter />);
117
+ expect(() => ReactNoop.flush()).toThrow(
118
+ 'Hooks can only be called inside the body of a functional component.',
119
+ );
120
+
121
+ // Confirm that a subsequent hook works properly.
122
+ function GoodCounter(props) {
123
+ const [count] = useState(props.initialCount);
124
+ return <Text text={count} />;
125
+ }
126
+ ReactNoop.render(<GoodCounter initialCount={10} />);
127
+ expect(ReactNoop.flush()).toEqual([10]);
128
+ });
129
+
130
+ it('throws when called outside the render phase', () => {
131
+ expect(() => useState(0)).toThrow(
132
+ 'Hooks can only be called inside the body of a functional component.',
133
+ );
134
+ });
135
+
136
+ describe('useState', () => {
137
+ it('simple mount and update', () => {
138
+ function Counter(props, ref) {
139
+ const [count, updateCount] = useState(0);
140
+ useAPI(ref, () => ({updateCount}));
141
+ return <Text text={'Count: ' + count} />;
142
+ }
143
+ Counter = forwardRef(Counter);
144
+ const counter = React.createRef(null);
145
+ ReactNoop.render(<Counter ref={counter} />);
146
+ ReactNoop.flush();
147
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
148
+
149
+ counter.current.updateCount(1);
150
+ ReactNoop.flush();
151
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
152
+
153
+ counter.current.updateCount(count => count + 10);
154
+ ReactNoop.flush();
155
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
156
+ });
157
+
158
+ it('lazy state initializer', () => {
159
+ function Counter(props, ref) {
160
+ const [count, updateCount] = useState(() => {
161
+ ReactNoop.yield('getInitialState');
162
+ return props.initialState;
163
+ });
164
+ useAPI(ref, () => ({updateCount}));
165
+ return <Text text={'Count: ' + count} />;
166
+ }
167
+ Counter = forwardRef(Counter);
168
+ const counter = React.createRef(null);
169
+ ReactNoop.render(<Counter initialState={42} ref={counter} />);
170
+ expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']);
171
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]);
172
+
173
+ counter.current.updateCount(7);
174
+ expect(ReactNoop.flush()).toEqual(['Count: 7']);
175
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]);
176
+ });
177
+
178
+ it('multiple states', () => {
179
+ function Counter(props, ref) {
180
+ const [count, updateCount] = useState(0);
181
+ const [label, updateLabel] = useState('Count');
182
+ useAPI(ref, () => ({updateCount, updateLabel}));
183
+ return <Text text={label + ': ' + count} />;
184
+ }
185
+ Counter = forwardRef(Counter);
186
+ const counter = React.createRef(null);
187
+ ReactNoop.render(<Counter ref={counter} />);
188
+ ReactNoop.flush();
189
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
190
+
191
+ counter.current.updateCount(7);
192
+ expect(ReactNoop.flush()).toEqual(['Count: 7']);
193
+
194
+ counter.current.updateLabel('Total');
195
+ expect(ReactNoop.flush()).toEqual(['Total: 7']);
196
+ });
197
+
198
+ it('callbacks', () => {
199
+ function Counter(props, ref) {
200
+ const [count, updateCount] = useState(0);
201
+ useAPI(ref, () => ({updateCount}));
202
+ return <Text text={'Count: ' + count} />;
203
+ }
204
+ Counter = forwardRef(Counter);
205
+ const counter = React.createRef(null);
206
+ ReactNoop.render(<Counter ref={counter} />);
207
+ ReactNoop.flush();
208
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
209
+
210
+ counter.current.updateCount(7, count => {
211
+ ReactNoop.yield(`Did update count`);
212
+ });
213
+ expect(ReactNoop.flush()).toEqual(['Count: 7', 'Did update count']);
214
+
215
+ // Update twice in the same batch
216
+ counter.current.updateCount(1, () => {
217
+ ReactNoop.yield(`Did update count (first callback)`);
218
+ });
219
+ counter.current.updateCount(2, () => {
220
+ ReactNoop.yield(`Did update count (second callback)`);
221
+ });
222
+ expect(ReactNoop.flush()).toEqual([
223
+ // Component only renders once
224
+ 'Count: 2',
225
+ 'Did update count (first callback)',
226
+ 'Did update count (second callback)',
227
+ ]);
228
+ });
229
+
230
+ it('does not fire callbacks more than once when rebasing', () => {
231
+ function Counter(props, ref) {
232
+ const [count, updateCount] = useState(0);
233
+ useAPI(ref, () => ({updateCount}));
234
+ return <Text text={'Count: ' + count} />;
235
+ }
236
+ Counter = forwardRef(Counter);
237
+ const counter = React.createRef(null);
238
+ ReactNoop.render(<Counter ref={counter} />);
239
+ ReactNoop.flush();
240
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
241
+
242
+ counter.current.updateCount(1, count => {
243
+ ReactNoop.yield(`Did update count (low pri)`);
244
+ });
245
+ ReactNoop.flushSync(() => {
246
+ counter.current.updateCount(2, count => {
247
+ ReactNoop.yield(`Did update count (high pri)`);
248
+ });
249
+ });
250
+ expect(ReactNoop.clearYields()).toEqual([
251
+ 'Count: 2',
252
+ 'Did update count (high pri)',
253
+ ]);
254
+ // The high-pri update is processed again when we render at low priority,
255
+ // but its callback should not fire again.
256
+ expect(ReactNoop.flush()).toEqual([
257
+ 'Count: 2',
258
+ 'Did update count (low pri)',
259
+ ]);
260
+ });
261
+
262
+ it('returns the same updater function every time', () => {
263
+ let updaters = [];
264
+ function Counter() {
265
+ const [count, updateCount] = useState(0);
266
+ updaters.push(updateCount);
267
+ return <Text text={'Count: ' + count} />;
268
+ }
269
+ ReactNoop.render(<Counter />);
270
+ ReactNoop.flush();
271
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
272
+
273
+ updaters[0](1);
274
+ ReactNoop.flush();
275
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
276
+
277
+ updaters[0](count => count + 10);
278
+ ReactNoop.flush();
279
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
280
+
281
+ expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]);
282
+ });
283
+ });
284
+
285
+ describe('updates during the render phase', () => {
286
+ it('restarts the render function and applies the new updates on top', () => {
287
+ function ScrollView({row: newRow}) {
288
+ let [isScrollingDown, setIsScrollingDown] = useState(false);
289
+ let [row, setRow] = useState(null);
290
+
291
+ if (row !== newRow) {
292
+ // Row changed since last render. Update isScrollingDown.
293
+ setIsScrollingDown(row !== null && newRow > row);
294
+ setRow(newRow);
295
+ }
296
+
297
+ return <Text text={`Scrolling down: ${isScrollingDown}`} />;
298
+ }
299
+
300
+ ReactNoop.render(<ScrollView row={1} />);
301
+ ReactNoop.flush();
302
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]);
303
+
304
+ ReactNoop.render(<ScrollView row={5} />);
305
+ ReactNoop.flush();
306
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]);
307
+
308
+ ReactNoop.render(<ScrollView row={5} />);
309
+ ReactNoop.flush();
310
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]);
311
+
312
+ ReactNoop.render(<ScrollView row={10} />);
313
+ ReactNoop.flush();
314
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]);
315
+
316
+ ReactNoop.render(<ScrollView row={2} />);
317
+ ReactNoop.flush();
318
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]);
319
+
320
+ ReactNoop.render(<ScrollView row={2} />);
321
+ ReactNoop.flush();
322
+ expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]);
323
+ });
324
+
325
+ it('keeps restarting until there are no more new updates', () => {
326
+ function Counter({row: newRow}) {
327
+ let [count, setCount] = useState(0);
328
+ if (count < 3) {
329
+ setCount(count + 1);
330
+ }
331
+ ReactNoop.yield('Render: ' + count);
332
+ return <Text text={count} />;
333
+ }
334
+
335
+ ReactNoop.render(<Counter />);
336
+ expect(ReactNoop.flush()).toEqual([
337
+ 'Render: 0',
338
+ 'Render: 1',
339
+ 'Render: 2',
340
+ 'Render: 3',
341
+ 3,
342
+ ]);
343
+ expect(ReactNoop.getChildren()).toEqual([span(3)]);
344
+ });
345
+
346
+ it('updates multiple times within same render function', () => {
347
+ function Counter({row: newRow}) {
348
+ let [count, setCount] = useState(0);
349
+ if (count < 12) {
350
+ setCount(c => c + 1);
351
+ setCount(c => c + 1);
352
+ setCount(c => c + 1);
353
+ }
354
+ ReactNoop.yield('Render: ' + count);
355
+ return <Text text={count} />;
356
+ }
357
+
358
+ ReactNoop.render(<Counter />);
359
+ expect(ReactNoop.flush()).toEqual([
360
+ // Should increase by three each time
361
+ 'Render: 0',
362
+ 'Render: 3',
363
+ 'Render: 6',
364
+ 'Render: 9',
365
+ 'Render: 12',
366
+ 12,
367
+ ]);
368
+ expect(ReactNoop.getChildren()).toEqual([span(12)]);
369
+ });
370
+
371
+ it('throws after too many iterations', () => {
372
+ function Counter({row: newRow}) {
373
+ let [count, setCount] = useState(0);
374
+ setCount(count + 1);
375
+ ReactNoop.yield('Render: ' + count);
376
+ return <Text text={count} />;
377
+ }
378
+ ReactNoop.render(<Counter />);
379
+ expect(() => ReactNoop.flush()).toThrow(
380
+ 'Too many re-renders. React limits the number of renders to prevent ' +
381
+ 'an infinite loop.',
382
+ );
383
+ });
384
+
385
+ it('works with useReducer', () => {
386
+ function reducer(state, action) {
387
+ return action === 'increment' ? state + 1 : state;
388
+ }
389
+ function Counter({row: newRow}) {
390
+ let [count, dispatch] = useReducer(reducer, 0);
391
+ if (count < 3) {
392
+ dispatch('increment');
393
+ }
394
+ ReactNoop.yield('Render: ' + count);
395
+ return <Text text={count} />;
396
+ }
397
+
398
+ ReactNoop.render(<Counter />);
399
+ expect(ReactNoop.flush()).toEqual([
400
+ 'Render: 0',
401
+ 'Render: 1',
402
+ 'Render: 2',
403
+ 'Render: 3',
404
+ 3,
405
+ ]);
406
+ expect(ReactNoop.getChildren()).toEqual([span(3)]);
407
+ });
408
+
409
+ it('uses reducer passed at time of render, not time of dispatch', () => {
410
+ // This test is a bit contrived but it demonstrates a subtle edge case.
411
+
412
+ // Reducer A increments by 1. Reducer B increments by 10.
413
+ function reducerA(state, action) {
414
+ switch (action) {
415
+ case 'increment':
416
+ return state + 1;
417
+ case 'reset':
418
+ return 0;
419
+ }
420
+ }
421
+ function reducerB(state, action) {
422
+ switch (action) {
423
+ case 'increment':
424
+ return state + 10;
425
+ case 'reset':
426
+ return 0;
427
+ }
428
+ }
429
+
430
+ function Counter({row: newRow}, ref) {
431
+ let [reducer, setReducer] = useState(() => reducerA);
432
+ let [count, dispatch] = useReducer(reducer, 0);
433
+ useAPI(ref, () => ({dispatch}));
434
+ if (count < 20) {
435
+ dispatch('increment');
436
+ // Swap reducers each time we increment
437
+ if (reducer === reducerA) {
438
+ setReducer(() => reducerB);
439
+ } else {
440
+ setReducer(() => reducerA);
441
+ }
442
+ }
443
+ ReactNoop.yield('Render: ' + count);
444
+ return <Text text={count} />;
445
+ }
446
+ Counter = forwardRef(Counter);
447
+ const counter = React.createRef(null);
448
+ ReactNoop.render(<Counter ref={counter} />);
449
+ expect(ReactNoop.flush()).toEqual([
450
+ // The count should increase by alternating amounts of 10 and 1
451
+ // until we reach 21.
452
+ 'Render: 0',
453
+ 'Render: 10',
454
+ 'Render: 11',
455
+ 'Render: 21',
456
+ 21,
457
+ ]);
458
+ expect(ReactNoop.getChildren()).toEqual([span(21)]);
459
+
460
+ // Test that it works on update, too. This time the log is a bit different
461
+ // because we started with reducerB instead of reducerA.
462
+ counter.current.dispatch('reset');
463
+ ReactNoop.render(<Counter ref={counter} />);
464
+ expect(ReactNoop.flush()).toEqual([
465
+ 'Render: 0',
466
+ 'Render: 1',
467
+ 'Render: 11',
468
+ 'Render: 12',
469
+ 'Render: 22',
470
+ 22,
471
+ ]);
472
+ expect(ReactNoop.getChildren()).toEqual([span(22)]);
473
+ });
474
+ });
475
+
476
+ describe('useReducer', () => {
477
+ it('simple mount and update', () => {
478
+ const INCREMENT = 'INCREMENT';
479
+ const DECREMENT = 'DECREMENT';
480
+
481
+ function reducer(state, action) {
482
+ switch (action) {
483
+ case 'INCREMENT':
484
+ return state + 1;
485
+ case 'DECREMENT':
486
+ return state - 1;
487
+ default:
488
+ return state;
489
+ }
490
+ }
491
+
492
+ function Counter(props, ref) {
493
+ const [count, dispatch] = useReducer(reducer, 0);
494
+ useAPI(ref, () => ({dispatch}));
495
+ return <Text text={'Count: ' + count} />;
496
+ }
497
+ Counter = forwardRef(Counter);
498
+ const counter = React.createRef(null);
499
+ ReactNoop.render(<Counter ref={counter} />);
500
+ ReactNoop.flush();
501
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
502
+
503
+ counter.current.dispatch(INCREMENT);
504
+ ReactNoop.flush();
505
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
506
+
507
+ counter.current.dispatch(DECREMENT);
508
+ counter.current.dispatch(DECREMENT);
509
+ counter.current.dispatch(DECREMENT);
510
+ ReactNoop.flush();
511
+ expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]);
512
+ });
513
+
514
+ it('accepts an initial action', () => {
515
+ const INCREMENT = 'INCREMENT';
516
+ const DECREMENT = 'DECREMENT';
517
+
518
+ function reducer(state, action) {
519
+ switch (action) {
520
+ case 'INITIALIZE':
521
+ return 10;
522
+ case 'INCREMENT':
523
+ return state + 1;
524
+ case 'DECREMENT':
525
+ return state - 1;
526
+ default:
527
+ return state;
528
+ }
529
+ }
530
+
531
+ const initialAction = 'INITIALIZE';
532
+
533
+ function Counter(props, ref) {
534
+ const [count, dispatch] = useReducer(reducer, 0, initialAction);
535
+ useAPI(ref, () => ({dispatch}));
536
+ return <Text text={'Count: ' + count} />;
537
+ }
538
+ Counter = forwardRef(Counter);
539
+ const counter = React.createRef(null);
540
+ ReactNoop.render(<Counter ref={counter} />);
541
+ ReactNoop.flush();
542
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]);
543
+
544
+ counter.current.dispatch(INCREMENT);
545
+ ReactNoop.flush();
546
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
547
+
548
+ counter.current.dispatch(DECREMENT);
549
+ counter.current.dispatch(DECREMENT);
550
+ counter.current.dispatch(DECREMENT);
551
+ ReactNoop.flush();
552
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]);
553
+ });
554
+ });
555
+
556
+ describe('useEffect', () => {
557
+ it('simple mount and update', () => {
558
+ function Counter(props) {
559
+ useEffect(() => {
560
+ ReactNoop.yield(`Did commit [${props.count}]`);
561
+ });
562
+ return <Text text={'Count: ' + props.count} />;
563
+ }
564
+ ReactNoop.render(<Counter count={0} />);
565
+ expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did commit [0]']);
566
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
567
+
568
+ ReactNoop.render(<Counter count={1} />);
569
+ expect(ReactNoop.flush()).toEqual(['Count: 1', 'Did commit [1]']);
570
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
571
+ });
572
+
573
+ it('unmounts previous effect', () => {
574
+ function Counter(props) {
575
+ useEffect(() => {
576
+ ReactNoop.yield(`Did create [${props.count}]`);
577
+ return () => {
578
+ ReactNoop.yield(`Did destroy [${props.count}]`);
579
+ };
580
+ });
581
+ return <Text text={'Count: ' + props.count} />;
582
+ }
583
+ ReactNoop.render(<Counter count={0} />);
584
+ expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']);
585
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
586
+
587
+ ReactNoop.render(<Counter count={1} />);
588
+ expect(ReactNoop.flush()).toEqual([
589
+ 'Count: 1',
590
+ 'Did destroy [0]',
591
+ 'Did create [1]',
592
+ ]);
593
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
594
+ });
595
+
596
+ it('unmounts on deletion', () => {
597
+ function Counter(props) {
598
+ useEffect(() => {
599
+ ReactNoop.yield(`Did create [${props.count}]`);
600
+ return () => {
601
+ ReactNoop.yield(`Did destroy [${props.count}]`);
602
+ };
603
+ });
604
+ return <Text text={'Count: ' + props.count} />;
605
+ }
606
+ ReactNoop.render(<Counter count={0} />);
607
+ expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']);
608
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
609
+
610
+ ReactNoop.render(null);
611
+ expect(ReactNoop.flush()).toEqual(['Did destroy [0]']);
612
+ expect(ReactNoop.getChildren()).toEqual([]);
613
+ });
614
+
615
+ it('skips effect if constructor has not changed', () => {
616
+ function effect() {
617
+ ReactNoop.yield(`Did mount`);
618
+ return () => {
619
+ ReactNoop.yield(`Did unmount`);
620
+ };
621
+ }
622
+ function Counter(props) {
623
+ useEffect(effect);
624
+ return <Text text={'Count: ' + props.count} />;
625
+ }
626
+ ReactNoop.render(<Counter count={0} />);
627
+ expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did mount']);
628
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
629
+
630
+ ReactNoop.render(<Counter count={1} />);
631
+ // No effect, because constructor was hoisted outside render
632
+ expect(ReactNoop.flush()).toEqual(['Count: 1']);
633
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
634
+
635
+ ReactNoop.render(null);
636
+ expect(ReactNoop.flush()).toEqual(['Did unmount']);
637
+ expect(ReactNoop.getChildren()).toEqual([]);
638
+ });
639
+
640
+ it('skips effect if inputs have not changed', () => {
641
+ function Counter(props) {
642
+ const text = `${props.label}: ${props.count}`;
643
+ useEffect(
644
+ () => {
645
+ ReactNoop.yield(`Did create [${text}]`);
646
+ return () => {
647
+ ReactNoop.yield(`Did destroy [${text}]`);
648
+ };
649
+ },
650
+ [props.label, props.count],
651
+ );
652
+ return <Text text={text} />;
653
+ }
654
+ ReactNoop.render(<Counter label="Count" count={0} />);
655
+ expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [Count: 0]']);
656
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
657
+
658
+ ReactNoop.render(<Counter label="Count" count={1} />);
659
+ // Count changed
660
+ expect(ReactNoop.flush()).toEqual([
661
+ 'Count: 1',
662
+ 'Did destroy [Count: 0]',
663
+ 'Did create [Count: 1]',
664
+ ]);
665
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
666
+
667
+ ReactNoop.render(<Counter label="Count" count={1} />);
668
+ // Nothing changed, so no effect should have fired
669
+ expect(ReactNoop.flush()).toEqual(['Count: 1']);
670
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
671
+
672
+ ReactNoop.render(<Counter label="Total" count={1} />);
673
+ // Label changed
674
+ expect(ReactNoop.flush()).toEqual([
675
+ 'Total: 1',
676
+ 'Did destroy [Count: 1]',
677
+ 'Did create [Total: 1]',
678
+ ]);
679
+ expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]);
680
+ });
681
+
682
+ it('multiple effects', () => {
683
+ function Counter(props) {
684
+ useEffect(() => {
685
+ ReactNoop.yield(`Did commit 1 [${props.count}]`);
686
+ });
687
+ useEffect(() => {
688
+ ReactNoop.yield(`Did commit 2 [${props.count}]`);
689
+ });
690
+ return <Text text={'Count: ' + props.count} />;
691
+ }
692
+ ReactNoop.render(<Counter count={0} />);
693
+ expect(ReactNoop.flush()).toEqual([
694
+ 'Count: 0',
695
+ 'Did commit 1 [0]',
696
+ 'Did commit 2 [0]',
697
+ ]);
698
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
699
+
700
+ ReactNoop.render(<Counter count={1} />);
701
+ expect(ReactNoop.flush()).toEqual([
702
+ 'Count: 1',
703
+ 'Did commit 1 [1]',
704
+ 'Did commit 2 [1]',
705
+ ]);
706
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
707
+ });
708
+
709
+ it('unmounts all previous effects before creating any new ones', () => {
710
+ function Counter(props) {
711
+ useEffect(() => {
712
+ ReactNoop.yield(`Mount A [${props.count}]`);
713
+ return () => {
714
+ ReactNoop.yield(`Unmount A [${props.count}]`);
715
+ };
716
+ });
717
+ useEffect(() => {
718
+ ReactNoop.yield(`Mount B [${props.count}]`);
719
+ return () => {
720
+ ReactNoop.yield(`Unmount B [${props.count}]`);
721
+ };
722
+ });
723
+ return <Text text={'Count: ' + props.count} />;
724
+ }
725
+ ReactNoop.render(<Counter count={0} />);
726
+ expect(ReactNoop.flush()).toEqual([
727
+ 'Count: 0',
728
+ 'Mount A [0]',
729
+ 'Mount B [0]',
730
+ ]);
731
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
732
+
733
+ ReactNoop.render(<Counter count={1} />);
734
+ expect(ReactNoop.flush()).toEqual([
735
+ 'Count: 1',
736
+ 'Unmount A [0]',
737
+ 'Unmount B [0]',
738
+ 'Mount A [1]',
739
+ 'Mount B [1]',
740
+ ]);
741
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
742
+ });
743
+ });
744
+
745
+ describe('useCallback', () => {
746
+ it('memoizes callback by comparing inputs', () => {
747
+ class IncrementButton extends React.PureComponent {
748
+ increment = () => {
749
+ this.props.increment();
750
+ };
751
+ render() {
752
+ return <Text text="Increment" />;
753
+ }
754
+ }
755
+
756
+ function Counter({incrementBy}) {
757
+ const [count, updateCount] = useState(0);
758
+ const increment = useCallback(() => updateCount(c => c + incrementBy), [
759
+ incrementBy,
760
+ ]);
761
+ return (
762
+ <React.Fragment>
763
+ <IncrementButton increment={increment} ref={button} />
764
+ <Text text={'Count: ' + count} />
765
+ </React.Fragment>
766
+ );
767
+ }
768
+
769
+ const button = React.createRef(null);
770
+ ReactNoop.render(<Counter incrementBy={1} />);
771
+ expect(ReactNoop.flush()).toEqual(['Increment', 'Count: 0']);
772
+ expect(ReactNoop.getChildren()).toEqual([
773
+ span('Increment'),
774
+ span('Count: 0'),
775
+ ]);
776
+
777
+ button.current.increment();
778
+ expect(ReactNoop.flush()).toEqual([
779
+ // Button should not re-render, because its props haven't changed
780
+ // 'Increment',
781
+ 'Count: 1',
782
+ ]);
783
+ expect(ReactNoop.getChildren()).toEqual([
784
+ span('Increment'),
785
+ span('Count: 1'),
786
+ ]);
787
+
788
+ // Increase the increment amount
789
+ ReactNoop.render(<Counter incrementBy={10} />);
790
+ expect(ReactNoop.flush()).toEqual([
791
+ // Inputs did change this time
792
+ 'Increment',
793
+ 'Count: 1',
794
+ ]);
795
+ expect(ReactNoop.getChildren()).toEqual([
796
+ span('Increment'),
797
+ span('Count: 1'),
798
+ ]);
799
+
800
+ // Callback should have updated
801
+ button.current.increment();
802
+ expect(ReactNoop.flush()).toEqual(['Count: 11']);
803
+ expect(ReactNoop.getChildren()).toEqual([
804
+ span('Increment'),
805
+ span('Count: 11'),
806
+ ]);
807
+ });
808
+ });
809
+
810
+ describe('useMemo', () => {
811
+ it('memoizes value by comparing to previous inputs', () => {
812
+ function CapitalizedText(props) {
813
+ const text = props.text;
814
+ const capitalizedText = useMemo(
815
+ () => {
816
+ ReactNoop.yield(`Capitalize '${text}'`);
817
+ return text.toUpperCase();
818
+ },
819
+ [text],
820
+ );
821
+ return <Text text={capitalizedText} />;
822
+ }
823
+
824
+ ReactNoop.render(<CapitalizedText text="hello" />);
825
+ expect(ReactNoop.flush()).toEqual(["Capitalize 'hello'", 'HELLO']);
826
+ expect(ReactNoop.getChildren()).toEqual([span('HELLO')]);
827
+
828
+ ReactNoop.render(<CapitalizedText text="hi" />);
829
+ expect(ReactNoop.flush()).toEqual(["Capitalize 'hi'", 'HI']);
830
+ expect(ReactNoop.getChildren()).toEqual([span('HI')]);
831
+
832
+ ReactNoop.render(<CapitalizedText text="hi" />);
833
+ expect(ReactNoop.flush()).toEqual(['HI']);
834
+ expect(ReactNoop.getChildren()).toEqual([span('HI')]);
835
+
836
+ ReactNoop.render(<CapitalizedText text="goodbye" />);
837
+ expect(ReactNoop.flush()).toEqual(["Capitalize 'goodbye'", 'GOODBYE']);
838
+ expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]);
839
+ });
840
+
841
+ it('compares function if no inputs are provided', () => {
842
+ function LazyCompute(props) {
843
+ const computed = useMemo(props.compute);
844
+ return <Text text={computed} />;
845
+ }
846
+
847
+ function computeA() {
848
+ ReactNoop.yield('compute A');
849
+ return 'A';
850
+ }
851
+
852
+ function computeB() {
853
+ ReactNoop.yield('compute B');
854
+ return 'B';
855
+ }
856
+
857
+ ReactNoop.render(<LazyCompute compute={computeA} />);
858
+ expect(ReactNoop.flush()).toEqual(['compute A', 'A']);
859
+
860
+ ReactNoop.render(<LazyCompute compute={computeA} />);
861
+ expect(ReactNoop.flush()).toEqual(['A']);
862
+
863
+ ReactNoop.render(<LazyCompute compute={computeA} />);
864
+ expect(ReactNoop.flush()).toEqual(['A']);
865
+
866
+ ReactNoop.render(<LazyCompute compute={computeB} />);
867
+ expect(ReactNoop.flush()).toEqual(['compute B', 'B']);
868
+ });
869
+ });
870
+
871
+ describe('useRef', () => {
872
+ it('creates a ref object initialized with the provided value', () => {
873
+ jest.useFakeTimers();
874
+
875
+ function useDebouncedCallback(callback, ms, inputs) {
876
+ const timeoutID = useRef(-1);
877
+ useEffect(() => {
878
+ return function unmount() {
879
+ clearTimeout(timeoutID.current);
880
+ };
881
+ }, []);
882
+ const debouncedCallback = useCallback(
883
+ (...args) => {
884
+ clearTimeout(timeoutID.current);
885
+ timeoutID.current = setTimeout(callback, ms, ...args);
886
+ },
887
+ [callback, ms],
888
+ );
889
+ return useCallback(debouncedCallback, inputs);
890
+ }
891
+
892
+ let ping;
893
+ function App() {
894
+ ping = useDebouncedCallback(
895
+ value => {
896
+ ReactNoop.yield('ping: ' + value);
897
+ },
898
+ 100,
899
+ [],
900
+ );
901
+ return null;
902
+ }
903
+
904
+ ReactNoop.render(<App />);
905
+ expect(ReactNoop.flush()).toEqual([]);
906
+
907
+ ping(1);
908
+ ping(2);
909
+ ping(3);
910
+
911
+ expect(ReactNoop.flush()).toEqual([]);
912
+
913
+ jest.advanceTimersByTime(100);
914
+
915
+ expect(ReactNoop.flush()).toEqual(['ping: 3']);
916
+
917
+ ping(4);
918
+ jest.advanceTimersByTime(20);
919
+ ping(5);
920
+ ping(6);
921
+ jest.advanceTimersByTime(80);
922
+
923
+ expect(ReactNoop.flush()).toEqual([]);
924
+
925
+ jest.advanceTimersByTime(20);
926
+ expect(ReactNoop.flush()).toEqual(['ping: 6']);
927
+ });
928
+ });
929
+
930
+ describe('progressive enhancement', () => {
931
+ it('mount additional state', () => {
932
+ let updateA;
933
+ let updateB;
934
+ let updateC;
935
+
936
+ function App(props) {
937
+ const [A, _updateA] = useState(0);
938
+ const [B, _updateB] = useState(0);
939
+ updateA = _updateA;
940
+ updateB = _updateB;
941
+
942
+ let C;
943
+ if (props.loadC) {
944
+ const [_C, _updateC] = useState(0);
945
+ C = _C;
946
+ updateC = _updateC;
947
+ } else {
948
+ C = '[not loaded]';
949
+ }
950
+
951
+ return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;
952
+ }
953
+
954
+ ReactNoop.render(<App loadC={false} />);
955
+ expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: [not loaded]']);
956
+ expect(ReactNoop.getChildren()).toEqual([
957
+ span('A: 0, B: 0, C: [not loaded]'),
958
+ ]);
959
+
960
+ updateA(2);
961
+ updateB(3);
962
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
963
+ expect(ReactNoop.getChildren()).toEqual([
964
+ span('A: 2, B: 3, C: [not loaded]'),
965
+ ]);
966
+
967
+ ReactNoop.render(<App loadC={true} />);
968
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 0']);
969
+ expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]);
970
+
971
+ updateC(4);
972
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
973
+ expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
974
+ });
975
+
976
+ it('unmount state', () => {
977
+ let updateA;
978
+ let updateB;
979
+ let updateC;
980
+
981
+ function App(props) {
982
+ const [A, _updateA] = useState(0);
983
+ const [B, _updateB] = useState(0);
984
+ updateA = _updateA;
985
+ updateB = _updateB;
986
+
987
+ let C;
988
+ if (props.loadC) {
989
+ const [_C, _updateC] = useState(0);
990
+ C = _C;
991
+ updateC = _updateC;
992
+ } else {
993
+ C = '[not loaded]';
994
+ }
995
+
996
+ return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;
997
+ }
998
+
999
+ ReactNoop.render(<App loadC={true} />);
1000
+ expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']);
1001
+ expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]);
1002
+
1003
+ updateA(2);
1004
+ updateB(3);
1005
+ updateC(4);
1006
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
1007
+ expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
1008
+
1009
+ ReactNoop.render(<App loadC={false} />);
1010
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
1011
+ expect(ReactNoop.getChildren()).toEqual([
1012
+ span('A: 2, B: 3, C: [not loaded]'),
1013
+ ]);
1014
+
1015
+ updateC(4);
1016
+ // TODO: This hook triggered a re-render even though it's unmounted.
1017
+ // Should we warn?
1018
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
1019
+ expect(ReactNoop.getChildren()).toEqual([
1020
+ span('A: 2, B: 3, C: [not loaded]'),
1021
+ ]);
1022
+
1023
+ updateB(4);
1024
+ expect(ReactNoop.flush()).toEqual(['A: 2, B: 4, C: [not loaded]']);
1025
+ expect(ReactNoop.getChildren()).toEqual([
1026
+ span('A: 2, B: 4, C: [not loaded]'),
1027
+ ]);
1028
+ });
1029
+
1030
+ it('unmount effects', () => {
1031
+ function App(props) {
1032
+ useEffect(() => {
1033
+ ReactNoop.yield('Mount A');
1034
+ return () => {
1035
+ ReactNoop.yield('Unmount A');
1036
+ };
1037
+ }, []);
1038
+
1039
+ if (props.showMore) {
1040
+ useEffect(() => {
1041
+ ReactNoop.yield('Mount B');
1042
+ return () => {
1043
+ ReactNoop.yield('Unmount B');
1044
+ };
1045
+ }, []);
1046
+ }
1047
+
1048
+ return null;
1049
+ }
1050
+
1051
+ ReactNoop.render(<App showMore={false} />);
1052
+ expect(ReactNoop.flush()).toEqual(['Mount A']);
1053
+
1054
+ ReactNoop.render(<App showMore={true} />);
1055
+ expect(ReactNoop.flush()).toEqual(['Mount B']);
1056
+
1057
+ ReactNoop.render(<App showMore={false} />);
1058
+ expect(ReactNoop.flush()).toEqual(['Unmount B']);
1059
+
1060
+ ReactNoop.render(null);
1061
+ expect(ReactNoop.flush()).toEqual(['Unmount A']);
1062
+ });
1063
+ });
1064
+});
packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js
+11
-16
@@ -12,6 +12,7 @@
12
let ReactFeatureFlags = require('shared/ReactFeatureFlags');
13
14
let React = require('react');
15
+let useContext;
16
let ReactNoop;
17
let gen;
18
@@ -21,6 +22,7 @@ describe('ReactNewContext', () => {
22
ReactFeatureFlags = require('shared/ReactFeatureFlags');
23
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
24
React = require('react');
25
+ useContext = React.useContext;
26
ReactNoop = require('react-noop-renderer');
27
gen = require('random-seed');
28
});
@@ -34,33 +36,26 @@ describe('ReactNewContext', () => {
36
return {type: 'span', children: [], prop, hidden: false};
37
}
38
37
- function readContext(Context, observedBits) {
38
- const dispatcher =
39
- React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner
40
- .currentDispatcher;
41
- return dispatcher.readContext(Context, observedBits);
42
- }
43
-
39
// We have several ways of reading from context. sharedContextTests runs
40
// a suite of tests for a given context consumer implementation.
41
sharedContextTests('Context.Consumer', Context => Context.Consumer);
42
sharedContextTests(
48
- 'readContext(Context) inside function component',
43
+ 'useContext inside functional component',
44
Context =>
45
function Consumer(props) {
46
const observedBits = props.unstable_observedBits;
52
- const contextValue = readContext(Context, observedBits);
47
+ const contextValue = useContext(Context, observedBits);
48
const render = props.children;
49
return render(contextValue);
50
},
51
);
52
sharedContextTests(
58
- 'readContext(Context) inside class component',
53
+ 'useContext inside class component',
54
Context =>
55
class Consumer extends React.Component {
56
render() {
57
const observedBits = this.props.unstable_observedBits;
63
- const contextValue = readContext(Context, observedBits);
58
+ const contextValue = useContext(Context, observedBits);
59
const render = this.props.children;
60
return render(contextValue);
61
}
@@ -1194,7 +1189,7 @@ describe('ReactNewContext', () => {
1189
return (
1190
<FooContext.Consumer>
1191
{foo => {
1197
- const bar = readContext(BarContext);
1192
+ const bar = useContext(BarContext);
1193
return <Text text={`Foo: ${foo}, Bar: ${bar}`} />;
1194
}}
1195
</FooContext.Consumer>
@@ -1238,7 +1233,7 @@ describe('ReactNewContext', () => {
1233
});
1234
});
1235
1241
- describe('readContext', () => {
1236
+ describe('useContext', () => {
1237
it('can use the same context multiple times in the same function', () => {
1238
const Context = React.createContext({foo: 0, bar: 0, baz: 0}, (a, b) => {
1239
let result = 0;
@@ -1264,13 +1259,13 @@ describe('ReactNewContext', () => {
1259
}
1260
1261
function FooAndBar() {
1267
- const {foo} = readContext(Context, 0b001);
1268
- const {bar} = readContext(Context, 0b010);
1262
+ const {foo} = useContext(Context, 0b001);
1263
+ const {bar} = useContext(Context, 0b010);
1264
return <Text text={`Foo: ${foo}, Bar: ${bar}`} />;
1265
}
1266
1267
function Baz() {
1273
- const {baz} = readContext(Context, 0b100);
1268
+ const {baz} = useContext(Context, 0b100);
1269
return <Text text={'Baz: ' + baz} />;
1270
}
1271
packages/react/src/React.js
+19
@@ -27,6 +27,16 @@ import {createContext} from './ReactContext';
27
import {lazy} from './ReactLazy';
28
import forwardRef from './forwardRef';
29
import memo from './memo';
30
+import {
31
+ useContext,
32
+ useState,
33
+ useReducer,
34
+ useRef,
35
+ useEffect,
36
+ useCallback,
37
+ useMemo,
38
+ useAPI,
39
+} from './ReactHooks';
40
import {
41
createElementWithValidation,
42
createFactoryWithValidation,
@@ -53,6 +63,15 @@ const React = {
63
lazy,
64
memo,
65
66
+ useContext,
67
+ useState,
68
+ useReducer,
69
+ useRef,
70
+ useEffect,
71
+ useCallback,
72
+ useMemo,
73
+ useAPI,
74
+
75
Fragment: REACT_FRAGMENT_TYPE,
76
StrictMode: REACT_STRICT_MODE_TYPE,
77
Suspense: REACT_SUSPENSE_TYPE,
packages/react/src/ReactHooks.js
new
+83
@@ -0,0 +1,83 @@
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
+ * @flow
8
+ */
9
+
10
+import type {ReactContext} from 'shared/ReactTypes';
11
+
12
+import invariant from 'shared/invariant';
13
+
14
+import ReactCurrentOwner from './ReactCurrentOwner';
15
+
16
+function resolveDispatcher() {
17
+ const dispatcher = ReactCurrentOwner.currentDispatcher;
18
+ invariant(
19
+ dispatcher !== null,
20
+ 'Hooks can only be called inside the body of a functional component.',
21
+ );
22
+ return dispatcher;
23
+}
24
+
25
+export function useContext<T>(
26
+ Context: ReactContext<T>,
27
+ observedBits: number | boolean | void,
28
+) {
29
+ const dispatcher = resolveDispatcher();
30
+ return dispatcher.readContext(Context, observedBits);
31
+}
32
+
33
+export function useState<S>(initialState: S | (() => S)) {
34
+ const dispatcher = resolveDispatcher();
35
+ return dispatcher.useState(initialState);
36
+}
37
+
38
+export function useReducer<S, A>(
39
+ reducer: (S, A) => S,
40
+ initialState: S,
41
+ initialAction: A | void | null,
42
+) {
43
+ const dispatcher = resolveDispatcher();
44
+ return dispatcher.useReducer(reducer, initialState, initialAction);
45
+}
46
+
47
+export function useRef<T>(initialValue: T): {current: T} {
48
+ const dispatcher = resolveDispatcher();
49
+ return dispatcher.useRef(initialValue);
50
+}
51
+
52
+export function useEffect(
53
+ create: () => mixed,
54
+ inputs: Array<mixed> | void | null,
55
+) {
56
+ const dispatcher = resolveDispatcher();
57
+ return dispatcher.useEffect(create, inputs);
58
+}
59
+
60
+export function useCallback(
61
+ callback: () => mixed,
62
+ inputs: Array<mixed> | void | null,
63
+) {
64
+ const dispatcher = resolveDispatcher();
65
+ return dispatcher.useCallback(callback, inputs);
66
+}
67
+
68
+export function useMemo(
69
+ create: () => mixed,
70
+ inputs: Array<mixed> | void | null,
71
+) {
72
+ const dispatcher = resolveDispatcher();
73
+ return dispatcher.useMemo(create, inputs);
74
+}
75
+
76
+export function useAPI<T>(
77
+ ref: {current: T | null} | ((inst: T | null) => mixed) | null | void,
78
+ create: () => T,
79
+ inputs: Array<mixed> | void | null,
80
+): void {
81
+ const dispatcher = resolveDispatcher();
82
+ return dispatcher.useAPI(ref, create, inputs);
83
+}