From 4257646cbb16625bf51ede60a17be68eb34ab116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 11:36:44 +0100 Subject: [PATCH 01/11] Inline implementation of useReducer into useState --- .../react-reconciler/src/ReactFiberHooks.js | 300 +++++++++++++++++- .../src/__tests__/ReactHooks-test.internal.js | 4 +- 2 files changed, 291 insertions(+), 13 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 851e22f8fe835..36dc2e671c2b0 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -838,7 +838,7 @@ function rerenderReducer( } function mountState( - initialState: (() => S) | S, + initialState: BasicStateAction, ): [S, Dispatch>] { const hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { @@ -854,7 +854,7 @@ function mountState( }); const dispatch: Dispatch< BasicStateAction, - > = (queue.dispatch = (dispatchAction.bind( + > = (queue.dispatch = (setState.bind( null, currentlyRenderingFiber, queue, @@ -862,16 +862,185 @@ function mountState( return [hook.memoizedState, dispatch]; } -function updateState( - initialState: (() => S) | S, -): [S, Dispatch>] { - return updateReducer(basicStateReducer, (initialState: any)); +function updateState( + initialArg: I, +): [S, Dispatch] { + const hook = updateWorkInProgressHook(); + const queue = hook.queue; + invariant( + queue !== null, + 'Should have a queue. This is likely a bug in React. Please file an issue.', + ); + + queue.lastRenderedReducer = basicStateReducer; + + const current: Hook = (currentHook: any); + + // The last rebase update that is NOT part of the base state. + let baseQueue = current.baseQueue; + + // The last pending update that hasn't been processed yet. + let pendingQueue = queue.pending; + if (pendingQueue !== null) { + // We have new updates that haven't been processed yet. + // We'll add them to the base queue. + if (baseQueue !== null) { + // Merge the pending queue and the base queue. + let baseFirst = baseQueue.next; + let pendingFirst = pendingQueue.next; + baseQueue.next = pendingFirst; + pendingQueue.next = baseFirst; + } + current.baseQueue = baseQueue = pendingQueue; + queue.pending = null; + } + + if (baseQueue !== null) { + // We have a queue to process. + let first = baseQueue.next; + let newState = current.baseState; + + let newBaseState = null; + let newBaseQueueFirst = null; + let newBaseQueueLast = null; + let update = first; + do { + const updateExpirationTime = update.expirationTime; + if (updateExpirationTime < renderExpirationTime) { + // Priority is insufficient. Skip this update. If this is the first + // skipped update, the previous update/state is the new base + // update/state. + const clone: Update = { + expirationTime: update.expirationTime, + suspenseConfig: update.suspenseConfig, + action: update.action, + eagerReducer: update.eagerReducer, + eagerState: update.eagerState, + next: (null: any), + }; + if (newBaseQueueLast === null) { + newBaseQueueFirst = newBaseQueueLast = clone; + newBaseState = newState; + } else { + newBaseQueueLast = newBaseQueueLast.next = clone; + } + // Update the remaining priority in the queue. + if (updateExpirationTime > currentlyRenderingFiber.expirationTime) { + currentlyRenderingFiber.expirationTime = updateExpirationTime; + markUnprocessedUpdateTime(updateExpirationTime); + } + } else { + // This update does have sufficient priority. + + if (newBaseQueueLast !== null) { + const clone: Update = { + expirationTime: Sync, // This update is going to be committed so we never want uncommit it. + suspenseConfig: update.suspenseConfig, + action: update.action, + eagerReducer: update.eagerReducer, + eagerState: update.eagerState, + next: (null: any), + }; + newBaseQueueLast = newBaseQueueLast.next = clone; + } + + // Mark the event time of this update as relevant to this render pass. + // TODO: This should ideally use the true event time of this update rather than + // its priority which is a derived and not reverseable value. + // TODO: We should skip this update if it was already committed but currently + // we have no way of detecting the difference between a committed and suspended + // update here. + markRenderEventTimeAndConfig( + updateExpirationTime, + update.suspenseConfig, + ); + + // Process this update. + if (update.eagerReducer === basicStateReducer) { + // If this update was processed eagerly, and its reducer matches the + // current reducer, we can use the eagerly computed state. + newState = ((update.eagerState: any): S); + } else { + const action = update.action; + newState = basicStateReducer(newState, action); + } + } + update = update.next; + } while (update !== null && update !== first); + + if (newBaseQueueLast === null) { + newBaseState = newState; + } else { + newBaseQueueLast.next = (newBaseQueueFirst: any); + } + + // Mark that the fiber performed work, but only if the new state is + // different from the current state. + if (!is(newState, hook.memoizedState)) { + markWorkInProgressReceivedUpdate(); + } + + hook.memoizedState = newState; + hook.baseState = newBaseState; + hook.baseQueue = newBaseQueueLast; + + queue.lastRenderedState = newState; + } + + const dispatch: Dispatch = (queue.dispatch: any); + return [hook.memoizedState, dispatch]; } -function rerenderState( - initialState: (() => S) | S, -): [S, Dispatch>] { - return rerenderReducer(basicStateReducer, (initialState: any)); +function rerenderState( + initialArg: I, +): [S, Dispatch] { + const hook = updateWorkInProgressHook(); + const queue = hook.queue; + invariant( + queue !== null, + 'Should have a queue. This is likely a bug in React. Please file an issue.', + ); + + queue.lastRenderedReducer = basicStateReducer; + + // This is a re-render. Apply the new render phase updates to the previous + // work-in-progress hook. + const dispatch: Dispatch = (queue.dispatch: any); + const lastRenderPhaseUpdate = queue.pending; + let newState = hook.memoizedState; + if (lastRenderPhaseUpdate !== null) { + // The queue doesn't persist past this render pass. + queue.pending = null; + + const firstRenderPhaseUpdate = lastRenderPhaseUpdate.next; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + newState = basicStateReducer(newState, action); + update = update.next; + } while (update !== firstRenderPhaseUpdate); + + // Mark that the fiber performed work, but only if the new state is + // different from the current state. + if (!is(newState, hook.memoizedState)) { + markWorkInProgressReceivedUpdate(); + } + + hook.memoizedState = newState; + // Don't persist the state accumulated from the render phase updates to + // the base state unless the queue is empty. + // TODO: Not sure if this is the desired semantics, but it's what we + // do for gDSFP. I can't remember why. + if (hook.baseQueue === null) { + hook.baseState = newState; + } + + queue.lastRenderedState = newState; + } + return [newState, dispatch]; } function pushEffect(tag, create, destroy, deps) { @@ -1268,7 +1437,116 @@ function dispatchAction( if (__DEV__) { if (typeof arguments[3] === 'function') { console.error( - "State updates from the useState() and useReducer() Hooks don't support the " + + "State updates from the useReducer() Hook don't support the " + + 'second callback argument. To execute a side effect after ' + + 'rendering, declare it in the component body with useEffect().', + ); + } + } + + const currentTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); + + const update: Update = { + expirationTime, + suspenseConfig, + action, + eagerReducer: null, + eagerState: null, + next: (null: any), + }; + + if (__DEV__) { + update.priority = getCurrentPriorityLevel(); + } + + // Append the update to the end of the list. + const pending = queue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + update.next = pending.next; + pending.next = update; + } + queue.pending = update; + + const alternate = fiber.alternate; + if ( + fiber === currentlyRenderingFiber || + (alternate !== null && alternate === currentlyRenderingFiber) + ) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true; + update.expirationTime = renderExpirationTime; + currentlyRenderingFiber.expirationTime = renderExpirationTime; + } else { + if ( + fiber.expirationTime === NoWork && + (alternate === null || alternate.expirationTime === NoWork) + ) { + // The queue is currently empty, which means we can eagerly compute the + // next state before entering the render phase. If the new state is the + // same as the current state, we may be able to bail out entirely. + const lastRenderedReducer = queue.lastRenderedReducer; + if (lastRenderedReducer !== null) { + let prevDispatcher; + if (__DEV__) { + prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + } + try { + const currentState: S = (queue.lastRenderedState: any); + const eagerState = lastRenderedReducer(currentState, action); + // Stash the eagerly computed state, and the reducer used to compute + // it, on the update object. If the reducer hasn't changed by the + // time we enter the render phase, then the eager state can be used + // without calling the reducer again. + update.eagerReducer = lastRenderedReducer; + update.eagerState = eagerState; + if (is(eagerState, currentState)) { + // Fast path. We can bail out without scheduling React to re-render. + // It's still possible that we'll need to rebase this update later, + // if the component re-renders for a different reason and by that + // time the reducer has changed. + return; + } + } catch (error) { + // Suppress the error. It will throw again in the render phase. + } finally { + if (__DEV__) { + ReactCurrentDispatcher.current = prevDispatcher; + } + } + } + } + if (__DEV__) { + // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests + if ('undefined' !== typeof jest) { + warnIfNotScopedWithMatchingAct(fiber); + warnIfNotCurrentlyActingUpdatesInDev(fiber); + } + } + scheduleWork(fiber, expirationTime); + } +} + +function setState( + fiber: Fiber, + queue: UpdateQueue, + action: BasicStateAction +) { + if (__DEV__) { + if (typeof arguments[3] === 'function') { + console.error( + "State updates from the useState() Hook don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().', ); diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index f9073f1bf7018..d92c7023adb65 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -270,7 +270,7 @@ describe('ReactHooks', () => { }), ); }).toErrorDev( - 'State updates from the useState() and useReducer() Hooks ' + + 'State updates from the useState() Hook ' + "don't support the second callback argument. " + 'To execute a side effect after rendering, ' + 'declare it in the component body with useEffect().', @@ -304,7 +304,7 @@ describe('ReactHooks', () => { }), ); }).toErrorDev( - 'State updates from the useState() and useReducer() Hooks ' + + 'State updates from the useReducer() Hook ' + "don't support the second callback argument. " + 'To execute a side effect after rendering, ' + 'declare it in the component body with useEffect().', From 8cec4dfcfd0474a813646d13671248685ddd89a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 11:59:31 +0100 Subject: [PATCH 02/11] Add failing test for dispatched actions being kept in the queue and processed incorrectly on subsequent rerenders --- ...eactHooksWithNoopRenderer-test.internal.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 81b774bd0a553..0af4cc322a271 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -3173,6 +3173,59 @@ function loadModules({ expect(ReactNoop).toMatchRenderedOutput('5'); }); + it('should not store dispatched items in the queue (TODO: better description)', () => { + let setDisabled; + let increment; + + function Counter({disabled}) { + const [count, dispatch] = useReducer((state, action) => { + if (disabled) { + return state; + } + if (action.type === 'increment') { + return state + 1; + } + return state; + }, 0); + + increment = () => dispatch({type: 'increment'}); + + Scheduler.unstable_yieldValue('Render count: ' + count); + return count; + } + + function App() { + const [disabled, _setDisabled] = useState(true); + setDisabled = _setDisabled; + Scheduler.unstable_yieldValue('Render disabled: ' + disabled); + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render disabled: true', + 'Render count: 0', + ]); + expect(ReactNoop).toMatchRenderedOutput('0'); + + act(() => { + increment(); + increment(); + increment(); + }); + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop).toMatchRenderedOutput('0'); + + act(() => { + setDisabled(false); + }); + expect(Scheduler).toHaveYielded([ + 'Render disabled: false', + 'Render count: 0', + ]); + expect(ReactNoop).toMatchRenderedOutput('0'); + }); + it('should process the rest pending updates after a render phase update', () => { // Similar to previous test, except using a preceding render phase update // instead of new props. From d1333c74a44dd11941021c748196a83dff9eada1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 13:46:27 +0100 Subject: [PATCH 03/11] Remove eagerReducer bailout optimization from useReducer --- .../react-reconciler/src/ReactFiberHooks.js | 39 -------------- ...eactHooksWithNoopRenderer-test.internal.js | 52 +------------------ 2 files changed, 1 insertion(+), 90 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 36dc2e671c2b0..89a87c1070dcd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1488,45 +1488,6 @@ function dispatchAction( update.expirationTime = renderExpirationTime; currentlyRenderingFiber.expirationTime = renderExpirationTime; } else { - if ( - fiber.expirationTime === NoWork && - (alternate === null || alternate.expirationTime === NoWork) - ) { - // The queue is currently empty, which means we can eagerly compute the - // next state before entering the render phase. If the new state is the - // same as the current state, we may be able to bail out entirely. - const lastRenderedReducer = queue.lastRenderedReducer; - if (lastRenderedReducer !== null) { - let prevDispatcher; - if (__DEV__) { - prevDispatcher = ReactCurrentDispatcher.current; - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; - } - try { - const currentState: S = (queue.lastRenderedState: any); - const eagerState = lastRenderedReducer(currentState, action); - // Stash the eagerly computed state, and the reducer used to compute - // it, on the update object. If the reducer hasn't changed by the - // time we enter the render phase, then the eager state can be used - // without calling the reducer again. - update.eagerReducer = lastRenderedReducer; - update.eagerState = eagerState; - if (is(eagerState, currentState)) { - // Fast path. We can bail out without scheduling React to re-render. - // It's still possible that we'll need to rebase this update later, - // if the component re-renders for a different reason and by that - // time the reducer has changed. - return; - } - } catch (error) { - // Suppress the error. It will throw again in the render phase. - } finally { - if (__DEV__) { - ReactCurrentDispatcher.current = prevDispatcher; - } - } - } - } if (__DEV__) { // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests if ('undefined' !== typeof jest) { diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 0af4cc322a271..1567d03707a78 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -3030,56 +3030,6 @@ function loadModules({ }); }); - it('eager bailout optimization should always compare to latest rendered reducer', () => { - // Edge case based on a bug report - let setCounter; - function App() { - const [counter, _setCounter] = useState(1); - setCounter = _setCounter; - return ; - } - - function Component({count}) { - const [state, dispatch] = useReducer(() => { - // This reducer closes over a value from props. If the reducer is not - // properly updated, the eager reducer will compare to an old value - // and bail out incorrectly. - Scheduler.unstable_yieldValue('Reducer: ' + count); - return count; - }, -1); - useEffect(() => { - Scheduler.unstable_yieldValue('Effect: ' + count); - dispatch(); - }, [count]); - Scheduler.unstable_yieldValue('Render: ' + state); - return count; - } - - act(() => { - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: -1', - 'Effect: 1', - 'Reducer: 1', - 'Reducer: 1', - 'Render: 1', - ]); - expect(ReactNoop).toMatchRenderedOutput('1'); - }); - - act(() => { - setCounter(2); - }); - expect(Scheduler).toHaveYielded([ - 'Render: 1', - 'Effect: 2', - 'Reducer: 2', - 'Reducer: 2', - 'Render: 2', - ]); - expect(ReactNoop).toMatchRenderedOutput('2'); - }); - // Regression test. Covers a case where an internal state variable // (`didReceiveUpdate`) is not reset properly. it('state bail out edge case (#16359)', async () => { @@ -3213,7 +3163,7 @@ function loadModules({ increment(); increment(); }); - expect(Scheduler).toHaveYielded([]); + expect(Scheduler).toHaveYielded(['Render count: 0']); expect(ReactNoop).toMatchRenderedOutput('0'); act(() => { From 50434b15b931a836f7d0f20c896275711c521a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 22:57:16 +0100 Subject: [PATCH 04/11] Remove constant condition --- .../react-reconciler/src/ReactFiberHooks.js | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 89a87c1070dcd..475f76b383360 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -872,8 +872,6 @@ function updateState( 'Should have a queue. This is likely a bug in React. Please file an issue.', ); - queue.lastRenderedReducer = basicStateReducer; - const current: Hook = (currentHook: any); // The last rebase update that is NOT part of the base state. @@ -1001,8 +999,6 @@ function rerenderState( 'Should have a queue. This is likely a bug in React. Please file an issue.', ); - queue.lastRenderedReducer = basicStateReducer; - // This is a re-render. Apply the new render phase updates to the previous // work-in-progress hook. const dispatch: Dispatch = (queue.dispatch: any); @@ -1566,34 +1562,32 @@ function setState( // next state before entering the render phase. If the new state is the // same as the current state, we may be able to bail out entirely. const lastRenderedReducer = queue.lastRenderedReducer; - if (lastRenderedReducer !== null) { - let prevDispatcher; - if (__DEV__) { - prevDispatcher = ReactCurrentDispatcher.current; - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + let prevDispatcher; + if (__DEV__) { + prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + } + try { + const currentState: S = (queue.lastRenderedState: any); + const eagerState = lastRenderedReducer(currentState, action); + // Stash the eagerly computed state, and the reducer used to compute + // it, on the update object. If the reducer hasn't changed by the + // time we enter the render phase, then the eager state can be used + // without calling the reducer again. + update.eagerReducer = lastRenderedReducer; + update.eagerState = eagerState; + if (is(eagerState, currentState)) { + // Fast path. We can bail out without scheduling React to re-render. + // It's still possible that we'll need to rebase this update later, + // if the component re-renders for a different reason and by that + // time the reducer has changed. + return; } - try { - const currentState: S = (queue.lastRenderedState: any); - const eagerState = lastRenderedReducer(currentState, action); - // Stash the eagerly computed state, and the reducer used to compute - // it, on the update object. If the reducer hasn't changed by the - // time we enter the render phase, then the eager state can be used - // without calling the reducer again. - update.eagerReducer = lastRenderedReducer; - update.eagerState = eagerState; - if (is(eagerState, currentState)) { - // Fast path. We can bail out without scheduling React to re-render. - // It's still possible that we'll need to rebase this update later, - // if the component re-renders for a different reason and by that - // time the reducer has changed. - return; - } - } catch (error) { - // Suppress the error. It will throw again in the render phase. - } finally { - if (__DEV__) { - ReactCurrentDispatcher.current = prevDispatcher; - } + } catch (error) { + // Suppress the error. It will throw again in the render phase. + } finally { + if (__DEV__) { + ReactCurrentDispatcher.current = prevDispatcher; } } } From d75c3890412782936c5beb8b823c48fe35c842c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 23:21:03 +0100 Subject: [PATCH 05/11] Remove Update.eagerReducer in favor of Update.eagerlyComputed --- .../react-reconciler/src/ReactFiberHooks.js | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 475f76b383360..5b9df7da3b886 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -103,7 +103,7 @@ type Update = {| expirationTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, action: A, - eagerReducer: ((S, A) => S) | null, + eagerlyComputed: false, eagerState: S | null, next: Update, priority?: ReactPriorityLevel, @@ -706,7 +706,7 @@ function updateReducer( expirationTime: update.expirationTime, suspenseConfig: update.suspenseConfig, action: update.action, - eagerReducer: update.eagerReducer, + eagerlyComputed: update.eagerlyComputed, eagerState: update.eagerState, next: (null: any), }; @@ -729,7 +729,7 @@ function updateReducer( expirationTime: Sync, // This update is going to be committed so we never want uncommit it. suspenseConfig: update.suspenseConfig, action: update.action, - eagerReducer: update.eagerReducer, + eagerlyComputed: update.eagerlyComputed, eagerState: update.eagerState, next: (null: any), }; @@ -747,15 +747,8 @@ function updateReducer( update.suspenseConfig, ); - // Process this update. - if (update.eagerReducer === reducer) { - // If this update was processed eagerly, and its reducer matches the - // current reducer, we can use the eagerly computed state. - newState = ((update.eagerState: any): S); - } else { - const action = update.action; - newState = reducer(newState, action); - } + const action = update.action; + newState = reducer(newState, action); } update = update.next; } while (update !== null && update !== first); @@ -912,7 +905,7 @@ function updateState( expirationTime: update.expirationTime, suspenseConfig: update.suspenseConfig, action: update.action, - eagerReducer: update.eagerReducer, + eagerlyComputed: update.eagerlyComputed, eagerState: update.eagerState, next: (null: any), }; @@ -935,7 +928,7 @@ function updateState( expirationTime: Sync, // This update is going to be committed so we never want uncommit it. suspenseConfig: update.suspenseConfig, action: update.action, - eagerReducer: update.eagerReducer, + eagerlyComputed: update.eagerlyComputed, eagerState: update.eagerState, next: (null: any), }; @@ -954,7 +947,7 @@ function updateState( ); // Process this update. - if (update.eagerReducer === basicStateReducer) { + if (update.eagerlyComputed) { // If this update was processed eagerly, and its reducer matches the // current reducer, we can use the eagerly computed state. newState = ((update.eagerState: any): S); @@ -1452,7 +1445,7 @@ function dispatchAction( expirationTime, suspenseConfig, action, - eagerReducer: null, + eagerlyComputed: false, eagerState: null, next: (null: any), }; @@ -1522,7 +1515,7 @@ function setState( expirationTime, suspenseConfig, action, - eagerReducer: null, + eagerlyComputed: false, eagerState: null, next: (null: any), }; @@ -1574,7 +1567,7 @@ function setState( // it, on the update object. If the reducer hasn't changed by the // time we enter the render phase, then the eager state can be used // without calling the reducer again. - update.eagerReducer = lastRenderedReducer; + update.eagerlyComputed = true; update.eagerState = eagerState; if (is(eagerState, currentState)) { // Fast path. We can bail out without scheduling React to re-render. From 3b5d4c5e5552e804a5f332ad2b3bd9cc0f889e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 23:24:46 +0100 Subject: [PATCH 06/11] Remove no longer needed UpdateQueue.lastRenderedReducer --- packages/react-reconciler/src/ReactFiberHooks.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 5b9df7da3b886..bef85a08ff14d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -112,7 +112,6 @@ type Update = {| type UpdateQueue = {| pending: Update | null, dispatch: (A => mixed) | null, - lastRenderedReducer: ((S, A) => S) | null, lastRenderedState: S | null, |}; @@ -641,7 +640,6 @@ function mountReducer( const queue = (hook.queue = { pending: null, dispatch: null, - lastRenderedReducer: reducer, lastRenderedState: (initialState: any), }); const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( @@ -664,8 +662,6 @@ function updateReducer( 'Should have a queue. This is likely a bug in React. Please file an issue.', ); - queue.lastRenderedReducer = reducer; - const current: Hook = (currentHook: any); // The last rebase update that is NOT part of the base state. @@ -788,8 +784,6 @@ function rerenderReducer( 'Should have a queue. This is likely a bug in React. Please file an issue.', ); - queue.lastRenderedReducer = reducer; - // This is a re-render. Apply the new render phase updates to the previous // work-in-progress hook. const dispatch: Dispatch = (queue.dispatch: any); @@ -842,7 +836,6 @@ function mountState( const queue = (hook.queue = { pending: null, dispatch: null, - lastRenderedReducer: basicStateReducer, lastRenderedState: (initialState: any), }); const dispatch: Dispatch< @@ -1554,7 +1547,6 @@ function setState( // The queue is currently empty, which means we can eagerly compute the // next state before entering the render phase. If the new state is the // same as the current state, we may be able to bail out entirely. - const lastRenderedReducer = queue.lastRenderedReducer; let prevDispatcher; if (__DEV__) { prevDispatcher = ReactCurrentDispatcher.current; @@ -1562,7 +1554,7 @@ function setState( } try { const currentState: S = (queue.lastRenderedState: any); - const eagerState = lastRenderedReducer(currentState, action); + const eagerState = basicStateReducer(currentState, action); // Stash the eagerly computed state, and the reducer used to compute // it, on the update object. If the reducer hasn't changed by the // time we enter the render phase, then the eager state can be used From c7843fc367a2c38d5e2e1732ca31e6214702e3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 23:35:28 +0100 Subject: [PATCH 07/11] Dont append updates to the queue on successful setState bailouts --- .../react-reconciler/src/ReactFiberHooks.js | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index bef85a08ff14d..101b826e9bafa 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1411,6 +1411,21 @@ function rerenderTransition( return [start, isPending]; } +function appendUpdate( + queue: UpdateQueue, + update: Update, +) { + const pending = queue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + update.next = pending.next; + pending.next = update; + } + queue.pending = update; +} + function dispatchAction( fiber: Fiber, queue: UpdateQueue, @@ -1447,16 +1462,7 @@ function dispatchAction( update.priority = getCurrentPriorityLevel(); } - // Append the update to the end of the list. - const pending = queue.pending; - if (pending === null) { - // This is the first update. Create a circular list. - update.next = update; - } else { - update.next = pending.next; - pending.next = update; - } - queue.pending = update; + appendUpdate(queue, update); const alternate = fiber.alternate; if ( @@ -1517,22 +1523,12 @@ function setState( update.priority = getCurrentPriorityLevel(); } - // Append the update to the end of the list. - const pending = queue.pending; - if (pending === null) { - // This is the first update. Create a circular list. - update.next = update; - } else { - update.next = pending.next; - pending.next = update; - } - queue.pending = update; - const alternate = fiber.alternate; if ( fiber === currentlyRenderingFiber || (alternate !== null && alternate === currentlyRenderingFiber) ) { + appendUpdate(queue, update); // This is a render phase update. Stash it in a lazily-created map of // queue -> linked list of updates. After this render pass, we'll restart // and apply the stashed updates on top of the work-in-progress hook. @@ -1555,12 +1551,6 @@ function setState( try { const currentState: S = (queue.lastRenderedState: any); const eagerState = basicStateReducer(currentState, action); - // Stash the eagerly computed state, and the reducer used to compute - // it, on the update object. If the reducer hasn't changed by the - // time we enter the render phase, then the eager state can be used - // without calling the reducer again. - update.eagerlyComputed = true; - update.eagerState = eagerState; if (is(eagerState, currentState)) { // Fast path. We can bail out without scheduling React to re-render. // It's still possible that we'll need to rebase this update later, @@ -1568,6 +1558,12 @@ function setState( // time the reducer has changed. return; } + // Stash the eagerly computed state, and the reducer used to compute + // it, on the update object. If the reducer hasn't changed by the + // time we enter the render phase, then the eager state can be used + // without calling the reducer again. + update.eagerlyComputed = true; + update.eagerState = eagerState; } catch (error) { // Suppress the error. It will throw again in the render phase. } finally { @@ -1583,6 +1579,8 @@ function setState( warnIfNotCurrentlyActingUpdatesInDev(fiber); } } + + appendUpdate(queue, update) scheduleWork(fiber, expirationTime); } } From 97a67daee05505a6ae67beaa120fdad5f470a628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 23:50:41 +0100 Subject: [PATCH 08/11] Fold back updateState into useReducer and rerenderState into rerenderReducer --- .../react-reconciler/src/ReactFiberHooks.js | 191 ++---------------- 1 file changed, 16 insertions(+), 175 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 101b826e9bafa..3e43811205439 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -743,8 +743,14 @@ function updateReducer( update.suspenseConfig, ); - const action = update.action; - newState = reducer(newState, action); + if (update.eagerlyComputed) { + // If this update was processed eagerly we can use the eagerly computed state. + // This can only happen for useState for which reducer is always the same. + newState = ((update.eagerState: any): S); + } else { + const action = update.action; + newState = reducer(newState, action); + } } update = update.next; } while (update !== null && update !== first); @@ -848,181 +854,16 @@ function mountState( return [hook.memoizedState, dispatch]; } -function updateState( - initialArg: I, -): [S, Dispatch] { - const hook = updateWorkInProgressHook(); - const queue = hook.queue; - invariant( - queue !== null, - 'Should have a queue. This is likely a bug in React. Please file an issue.', - ); - - const current: Hook = (currentHook: any); - - // The last rebase update that is NOT part of the base state. - let baseQueue = current.baseQueue; - - // The last pending update that hasn't been processed yet. - let pendingQueue = queue.pending; - if (pendingQueue !== null) { - // We have new updates that haven't been processed yet. - // We'll add them to the base queue. - if (baseQueue !== null) { - // Merge the pending queue and the base queue. - let baseFirst = baseQueue.next; - let pendingFirst = pendingQueue.next; - baseQueue.next = pendingFirst; - pendingQueue.next = baseFirst; - } - current.baseQueue = baseQueue = pendingQueue; - queue.pending = null; - } - - if (baseQueue !== null) { - // We have a queue to process. - let first = baseQueue.next; - let newState = current.baseState; - - let newBaseState = null; - let newBaseQueueFirst = null; - let newBaseQueueLast = null; - let update = first; - do { - const updateExpirationTime = update.expirationTime; - if (updateExpirationTime < renderExpirationTime) { - // Priority is insufficient. Skip this update. If this is the first - // skipped update, the previous update/state is the new base - // update/state. - const clone: Update = { - expirationTime: update.expirationTime, - suspenseConfig: update.suspenseConfig, - action: update.action, - eagerlyComputed: update.eagerlyComputed, - eagerState: update.eagerState, - next: (null: any), - }; - if (newBaseQueueLast === null) { - newBaseQueueFirst = newBaseQueueLast = clone; - newBaseState = newState; - } else { - newBaseQueueLast = newBaseQueueLast.next = clone; - } - // Update the remaining priority in the queue. - if (updateExpirationTime > currentlyRenderingFiber.expirationTime) { - currentlyRenderingFiber.expirationTime = updateExpirationTime; - markUnprocessedUpdateTime(updateExpirationTime); - } - } else { - // This update does have sufficient priority. - - if (newBaseQueueLast !== null) { - const clone: Update = { - expirationTime: Sync, // This update is going to be committed so we never want uncommit it. - suspenseConfig: update.suspenseConfig, - action: update.action, - eagerlyComputed: update.eagerlyComputed, - eagerState: update.eagerState, - next: (null: any), - }; - newBaseQueueLast = newBaseQueueLast.next = clone; - } - - // Mark the event time of this update as relevant to this render pass. - // TODO: This should ideally use the true event time of this update rather than - // its priority which is a derived and not reverseable value. - // TODO: We should skip this update if it was already committed but currently - // we have no way of detecting the difference between a committed and suspended - // update here. - markRenderEventTimeAndConfig( - updateExpirationTime, - update.suspenseConfig, - ); - - // Process this update. - if (update.eagerlyComputed) { - // If this update was processed eagerly, and its reducer matches the - // current reducer, we can use the eagerly computed state. - newState = ((update.eagerState: any): S); - } else { - const action = update.action; - newState = basicStateReducer(newState, action); - } - } - update = update.next; - } while (update !== null && update !== first); - - if (newBaseQueueLast === null) { - newBaseState = newState; - } else { - newBaseQueueLast.next = (newBaseQueueFirst: any); - } - - // Mark that the fiber performed work, but only if the new state is - // different from the current state. - if (!is(newState, hook.memoizedState)) { - markWorkInProgressReceivedUpdate(); - } - - hook.memoizedState = newState; - hook.baseState = newBaseState; - hook.baseQueue = newBaseQueueLast; - - queue.lastRenderedState = newState; - } - - const dispatch: Dispatch = (queue.dispatch: any); - return [hook.memoizedState, dispatch]; +function updateState( + initialState: BasicStateAction, +): [S, Dispatch>] { + return updateReducer(basicStateReducer, (initialState: any)); } -function rerenderState( - initialArg: I, -): [S, Dispatch] { - const hook = updateWorkInProgressHook(); - const queue = hook.queue; - invariant( - queue !== null, - 'Should have a queue. This is likely a bug in React. Please file an issue.', - ); - - // This is a re-render. Apply the new render phase updates to the previous - // work-in-progress hook. - const dispatch: Dispatch = (queue.dispatch: any); - const lastRenderPhaseUpdate = queue.pending; - let newState = hook.memoizedState; - if (lastRenderPhaseUpdate !== null) { - // The queue doesn't persist past this render pass. - queue.pending = null; - - const firstRenderPhaseUpdate = lastRenderPhaseUpdate.next; - let update = firstRenderPhaseUpdate; - do { - // Process this render phase update. We don't have to check the - // priority because it will always be the same as the current - // render's. - const action = update.action; - newState = basicStateReducer(newState, action); - update = update.next; - } while (update !== firstRenderPhaseUpdate); - - // Mark that the fiber performed work, but only if the new state is - // different from the current state. - if (!is(newState, hook.memoizedState)) { - markWorkInProgressReceivedUpdate(); - } - - hook.memoizedState = newState; - // Don't persist the state accumulated from the render phase updates to - // the base state unless the queue is empty. - // TODO: Not sure if this is the desired semantics, but it's what we - // do for gDSFP. I can't remember why. - if (hook.baseQueue === null) { - hook.baseState = newState; - } - - queue.lastRenderedState = newState; - } - return [newState, dispatch]; +function rerenderState( + initialState: (() => S) | S, +): [S, Dispatch>] { + return rerenderReducer(basicStateReducer, (initialState: any)); } function pushEffect(tag, create, destroy, deps) { From 9e520068d93cda14c145996614676cad33ef4505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 6 Mar 2020 23:58:36 +0100 Subject: [PATCH 09/11] fix Flow errors --- packages/react-reconciler/src/ReactFiberHooks.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 3e43811205439..7300d284ee5e3 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -103,7 +103,7 @@ type Update = {| expirationTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, action: A, - eagerlyComputed: false, + eagerlyComputed: boolean, eagerState: S | null, next: Update, priority?: ReactPriorityLevel, @@ -1330,7 +1330,7 @@ function dispatchAction( function setState( fiber: Fiber, - queue: UpdateQueue, + queue: UpdateQueue>, action: BasicStateAction ) { if (__DEV__) { @@ -1351,7 +1351,7 @@ function setState( suspenseConfig, ); - const update: Update = { + const update: Update> = { expirationTime, suspenseConfig, action, From 2aaa9cf6391fb31bef6b9ee6dea4aa8e5ded2053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sat, 7 Mar 2020 00:15:59 +0100 Subject: [PATCH 10/11] update code comments --- packages/react-reconciler/src/ReactFiberHooks.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 7300d284ee5e3..60acaf84ae875 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1394,15 +1394,10 @@ function setState( const eagerState = basicStateReducer(currentState, action); if (is(eagerState, currentState)) { // Fast path. We can bail out without scheduling React to re-render. - // It's still possible that we'll need to rebase this update later, - // if the component re-renders for a different reason and by that - // time the reducer has changed. return; } - // Stash the eagerly computed state, and the reducer used to compute - // it, on the update object. If the reducer hasn't changed by the - // time we enter the render phase, then the eager state can be used - // without calling the reducer again. + // Stash the eagerly computed state on the update object. + // It will be reused without without calling basicStateReducer again. update.eagerlyComputed = true; update.eagerState = eagerState; } catch (error) { From 897945fb49ed8172f5210303ca3e6a72ae0e0d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sat, 7 Mar 2020 00:31:45 +0100 Subject: [PATCH 11/11] format files with prettier --- packages/react-reconciler/src/ReactFiberHooks.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 60acaf84ae875..5cb32ca0fa46f 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1252,10 +1252,7 @@ function rerenderTransition( return [start, isPending]; } -function appendUpdate( - queue: UpdateQueue, - update: Update, -) { +function appendUpdate(queue: UpdateQueue, update: Update) { const pending = queue.pending; if (pending === null) { // This is the first update. Create a circular list. @@ -1331,9 +1328,9 @@ function dispatchAction( function setState( fiber: Fiber, queue: UpdateQueue>, - action: BasicStateAction + action: BasicStateAction, ) { - if (__DEV__) { + if (__DEV__) { if (typeof arguments[3] === 'function') { console.error( "State updates from the useState() Hook don't support the " + @@ -1416,7 +1413,7 @@ function setState( } } - appendUpdate(queue, update) + appendUpdate(queue, update); scheduleWork(fiber, expirationTime); } }