@samitouri / QOS-React / commits / 7bee9fbdd4

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 +}