Skip to content

Commit 121f6cb

Browse files
authored
arePropsEqualFuncWrapper (#254)
* wrapWithShouldUpdateExperimental * improve code readability * add test * improve code readability * update test comments * improve tests
1 parent c4c18a1 commit 121f6cb

2 files changed

Lines changed: 177 additions & 2 deletions

File tree

src/connectWithShell.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ function wrapWithShouldUpdate<Props extends unknown, F extends (next: Props, pre
3939
return ((...args: Parameters<F>) => (shouldUpdate && !shouldUpdate(shell, getOwnProps()) ? true : func(args[0], args[1]))) as F
4040
}
4141

42+
function arePropsEqualFuncWrapper<Props extends unknown, F extends (next: Props, prev: Props) => boolean>(
43+
componentShouldUpdateFunc: Maybe<(shell: Shell, ownProps?: Props) => boolean>,
44+
arePropsEqualFunc: F,
45+
getOwnProps: () => Props,
46+
shell: Shell
47+
): F {
48+
if (!componentShouldUpdateFunc) {
49+
return arePropsEqualFunc
50+
}
51+
let hasPendingPropChanges = false
52+
return ((...args: Parameters<F>) => {
53+
const componentShouldUpdate = componentShouldUpdateFunc(shell, getOwnProps())
54+
if (componentShouldUpdate) {
55+
if (hasPendingPropChanges) {
56+
hasPendingPropChanges = false
57+
return false
58+
}
59+
return arePropsEqualFunc(args[0], args[1])
60+
}
61+
if (!hasPendingPropChanges) {
62+
hasPendingPropChanges = !arePropsEqualFunc(args[0], args[1])
63+
}
64+
return true
65+
}) as F
66+
}
67+
4268
function wrapWithShellContext<State, OwnProps, StateProps, DispatchProps>(
4369
component: React.ComponentType<OwnProps & StateProps & DispatchProps>,
4470
mapStateToProps: MapStateToProps<State, OwnProps, StateProps>,
@@ -98,7 +124,7 @@ function wrapWithShellContext<State, OwnProps, StateProps, DispatchProps>(
98124
this.getOwnProps,
99125
boundShell
100126
),
101-
areOwnPropsEqual: wrapWithShouldUpdate(
127+
areOwnPropsEqual: arePropsEqualFuncWrapper(
102128
shouldComponentUpdate,
103129
reduxConnectOptions.areOwnPropsEqual,
104130
this.getOwnProps,

test/connectWithShell.spec.tsx

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
TOGGLE_MOCK_VALUE,
1515
collectAllTexts
1616
} from '../testKit'
17-
import { ReactTestRenderer, act, create } from 'react-test-renderer'
17+
import { ReactTestRenderer, act, create, ReactTestInstance } from 'react-test-renderer'
1818
import { AnyAction } from 'redux'
1919
import { ObservedSelectorsMap, observeWithShell } from '../src'
2020

@@ -256,6 +256,155 @@ describe('connectWithShell', () => {
256256
expect(ownPropsSpy).toHaveBeenCalledWith({ ownProp: true })
257257
})
258258

259+
describe('arePropsEqualFuncWrapper', () => {
260+
interface InnerCompDispatchProps {
261+
onClick(): void
262+
}
263+
interface InnerCompOwnProps {
264+
num: number
265+
}
266+
type InnerCompProps = InnerCompOwnProps & InnerCompDispatchProps
267+
interface OuterCompStateProps {
268+
num: number
269+
str: string
270+
}
271+
type OuterCompProps = OuterCompStateProps
272+
273+
// spies
274+
let mapDispatchInnerCompSpy: jest.Mock
275+
let innerComponentRender: jest.Mock
276+
let mapStateOuterCompSpy: jest.Mock
277+
let outerComponentRenderSpy: jest.Mock
278+
let innerCompOnClickSpy: jest.Mock
279+
let innerCompShouldComponentUpdateSpy: jest.Mock
280+
281+
// action helpers
282+
let shouldUpdateInnerComp: boolean
283+
let updateOuterComp: (newStateProps: OuterCompStateProps) => void
284+
285+
// assertion helpers
286+
let getInnerCompText: () => ReactTestInstance | string
287+
let invokeInnerCompOnClick: () => void
288+
289+
beforeEach(() => {
290+
const { host, shell, renderInShellContext } = createMocks(mockPackage)
291+
292+
// Setup - create connected inner Component
293+
innerComponentRender = jest.fn()
294+
mapDispatchInnerCompSpy = jest.fn()
295+
shouldUpdateInnerComp = false
296+
innerCompOnClickSpy = jest.fn(num => {})
297+
innerCompShouldComponentUpdateSpy = jest.fn((ownProps: InnerCompOwnProps) => {})
298+
299+
const mapDispatchToProps = (shell: Shell, state: unknown, ownProps?: InnerCompOwnProps): InnerCompDispatchProps => {
300+
mapDispatchInnerCompSpy()
301+
return {
302+
onClick: () => innerCompOnClickSpy(ownProps?.num || 0)
303+
}
304+
}
305+
306+
const PureInnerComp: FunctionComponent<InnerCompProps> = ({ num, onClick }) => {
307+
innerComponentRender()
308+
return <div onClick={onClick}>{num.toString()}</div>
309+
}
310+
311+
const ConnectedInnerComp = connectWithShell(undefined, mapDispatchToProps, shell, {
312+
shouldComponentUpdate: (shell, nextOwnProps) => {
313+
innerCompShouldComponentUpdateSpy(nextOwnProps)
314+
return shouldUpdateInnerComp
315+
},
316+
allowOutOfEntryPoint: true
317+
})(PureInnerComp)
318+
319+
// Setup - create connected outer Component
320+
mapStateOuterCompSpy = jest.fn()
321+
outerComponentRenderSpy = jest.fn()
322+
323+
let stateProps: OuterCompStateProps = { num: 1, str: 'initialState' }
324+
const mapStateToProps = (): OuterCompStateProps => {
325+
mapStateOuterCompSpy()
326+
return stateProps
327+
}
328+
329+
const PureOuterComp: FunctionComponent<OuterCompProps> = ({ num }) => {
330+
outerComponentRenderSpy()
331+
return <ConnectedInnerComp num={num} />
332+
}
333+
334+
const ConnectedOuterComp = connectWithShell(mapStateToProps, undefined, shell, {
335+
allowOutOfEntryPoint: true
336+
})(PureOuterComp)
337+
338+
updateOuterComp = (newStateProps: OuterCompStateProps) => {
339+
stateProps = newStateProps
340+
341+
act(() => {
342+
host.getStore().dispatch({ type: '' })
343+
host.getStore().flush()
344+
})
345+
}
346+
347+
// SetUp - use a reducer that creates a new state for any dispatched action
348+
let counter = 0
349+
host.getStore().replaceReducer(() => ({
350+
counter: ++counter
351+
}))
352+
353+
// Setup - render outer component
354+
const { testKit } = renderInShellContext(<ConnectedOuterComp />)
355+
356+
if (!testKit) {
357+
throw new Error('Connected component fail to render')
358+
}
359+
360+
// create assertion helpers
361+
getInnerCompText = () => testKit.root.findByType(ConnectedInnerComp).find(x => typeof x.children[0] === 'string').children[0]
362+
invokeInnerCompOnClick = () =>
363+
testKit.root
364+
.findByType(PureInnerComp)
365+
.find(x => x.type === 'div')
366+
.props.onClick()
367+
})
368+
it('should execute mapDispatchToProps, mapStateToProps, and render for both components during mount phase', () => {
369+
// Assert initial execution of mapping functions and components render
370+
expect(mapStateOuterCompSpy).toHaveBeenCalledTimes(1)
371+
expect(outerComponentRenderSpy).toHaveBeenCalledTimes(1)
372+
expect(mapDispatchInnerCompSpy).toHaveBeenCalledTimes(1)
373+
expect(innerComponentRender).toHaveBeenCalledTimes(1)
374+
expect(getInnerCompText()).toBe('1')
375+
invokeInnerCompOnClick()
376+
expect(innerCompOnClickSpy).toHaveBeenCalledWith(1)
377+
})
378+
it('should not trigger mapDispatchToProps or re-render the inner component when ownProps change while updates are blocked for the inner component', () => {
379+
// Act - update outer component, while updates for inner component are blocked
380+
updateOuterComp({ num: 2, str: 'nextState_1' })
381+
382+
// Assert - outer component re-rendered and passed new ownProps to inner component
383+
expect(mapStateOuterCompSpy).toHaveBeenCalledTimes(2)
384+
expect(outerComponentRenderSpy).toHaveBeenCalledTimes(2)
385+
expect(innerCompShouldComponentUpdateSpy).toHaveBeenCalledWith({ num: 2 })
386+
387+
// Assert - should not trigger mapDispatchToProps or re-render of inner component even though it's ownProps have changed
388+
expect(mapDispatchInnerCompSpy).toHaveBeenCalledTimes(1)
389+
expect(innerComponentRender).toHaveBeenCalledTimes(1)
390+
})
391+
it('should trigger recalculation of mergedProps with consideration of ownProps change once updates are permitted', () => {
392+
// Act - update outer component, while updates for inner component are blocked
393+
updateOuterComp({ num: 2, str: 'nextState_1' })
394+
395+
// Act - allow updates for inner component, then update outer component
396+
shouldUpdateInnerComp = true
397+
updateOuterComp({ num: 2, str: 'nextState_2' })
398+
399+
// Assert - mapDispatchToProps and re-render of inner component were triggered
400+
expect(mapDispatchInnerCompSpy).toHaveBeenCalledTimes(2)
401+
expect(innerComponentRender).toHaveBeenCalledTimes(2)
402+
expect(getInnerCompText()).toBe('2')
403+
invokeInnerCompOnClick()
404+
expect(innerCompOnClickSpy).toHaveBeenCalledWith(2)
405+
})
406+
})
407+
259408
it('should pass scoped state to mapStateToProps', () => {
260409
const { host, shell, renderInShellContext } = createMocks(mockPackage)
261410

0 commit comments

Comments
 (0)