diff --git a/hooks/src/index.js b/hooks/src/index.js index d05947eaaf..73e870ad74 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -184,7 +184,23 @@ export function useReducer(reducer, initialState, init) { if (!currentComponent._hasScuFromHooks) { currentComponent._hasScuFromHooks = true; - const prevScu = currentComponent.shouldComponentUpdate; + let prevScu = currentComponent.shouldComponentUpdate; + const prevCWU = currentComponent.componentWillUpdate; + + // If we're dealing with a forced update `shouldComponentUpdate` will + // not be called. But we use that to update the hook values, so we + // need to call it. + currentComponent.componentWillUpdate = function(p, s, c) { + if (this._force) { + let tmp = prevScu; + // Clear to avoid other sCU hooks from being called + prevScu = undefined; + updateHookState(p, s, c); + prevScu = tmp; + } + + if (prevCWU) prevCWU.call(this, p, s, c); + }; // This SCU has the purpose of bailing out after repeated updates // to stateful hooks. @@ -192,7 +208,13 @@ export function useReducer(reducer, initialState, init) { // state setters, if we have next states and // all next states within a component end up being equal to their original state // we are safe to bail out for this specific component. - currentComponent.shouldComponentUpdate = function(p, s, c) { + /** + * + * @type {import('./internal').Component["shouldComponentUpdate"]} + */ + // @ts-ignore - We don't use TS to downtranspile + // eslint-disable-next-line no-inner-declarations + function updateHookState(p, s, c) { if (!hookState._component.__hooks) return true; const stateHooks = hookState._component.__hooks._list.filter( @@ -223,7 +245,9 @@ export function useReducer(reducer, initialState, init) { ? prevScu.call(this, p, s, c) : true : false; - }; + } + + currentComponent.shouldComponentUpdate = updateHookState; } } diff --git a/hooks/test/browser/combinations.test.js b/hooks/test/browser/combinations.test.js index 2f6d33e139..02db5b74f2 100644 --- a/hooks/test/browser/combinations.test.js +++ b/hooks/test/browser/combinations.test.js @@ -1,5 +1,5 @@ import { setupRerender, act } from 'preact/test-utils'; -import { createElement, render, Component } from 'preact'; +import { createElement, render, Component, createContext } from 'preact'; import { setupScratch, teardown } from '../../../test/_util/helpers'; import { useState, @@ -7,7 +7,8 @@ import { useEffect, useLayoutEffect, useRef, - useMemo + useMemo, + useContext } from 'preact/hooks'; import { scheduleEffectAssert } from '../_util/useEffectUtil'; @@ -344,4 +345,57 @@ describe('combinations', () => { expect(ops).to.deep.equal(['child effect', 'parent effect']); }); + + it('should not block hook updates when context updates are enqueued', () => { + const Ctx = createContext({ + value: 0, + setValue: /** @type {*} */ () => {} + }); + + let triggerSubmit = () => {}; + function Child() { + const ctx = useContext(Ctx); + const [shouldSubmit, setShouldSubmit] = useState(false); + triggerSubmit = () => setShouldSubmit(true); + + useEffect(() => { + if (shouldSubmit) { + // Update parent state and child state at the same time + ctx.setValue(v => v + 1); + setShouldSubmit(false); + } + }, [shouldSubmit]); + + return

{ctx.value}

; + } + + function App() { + const [value, setValue] = useState(0); + const ctx = useMemo(() => { + return { value, setValue }; + }, [value]); + return ( + + + + ); + } + + act(() => { + render(, scratch); + }); + + expect(scratch.textContent).to.equal('0'); + + act(() => { + triggerSubmit(); + }); + expect(scratch.textContent).to.equal('1'); + + // This is where the update wasn't applied + act(() => { + triggerSubmit(); + }); + expect(scratch.textContent).to.equal('2'); + }); });