Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
251 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
# memo(Component, areEqual, lifecycleHooks) | ||
|
||
A higher-order component that memoizes and prevents re-renders of components if the props are unchanged. | ||
|
||
## Arguments | ||
|
||
| Argument | Type | Description | | ||
| :--------------- | :------------------------------- | :---------------------------------------- | | ||
| `Component` | `React.Component` | The React component. | | ||
| `areEqual` | `Function(prevProps, nextProps)` | Comparison function. Returns a `boolean`. | | ||
| `lifecycleHooks` | `Object` | Class-like lifecycle callback hooks | | ||
|
||
## Returns | ||
|
||
`React.Component`: The React component. | ||
|
||
## Examples | ||
|
||
```jsx | ||
import React from 'react' | ||
import memo from '@helpscout/react-utils/dist/memo' | ||
|
||
const Kip = props => <div {...props} /> | ||
const MemoizedKip = memo(Kip) | ||
``` | ||
|
||
Alternatively... | ||
|
||
```jsx | ||
import React from 'react' | ||
import memo from '@helpscout/react-utils/dist/memo' | ||
|
||
const Kip = Memo(props => <div {...props} />) | ||
``` | ||
|
||
## Lifecycle hooks | ||
|
||
### componentDidUpdate(prevProps, nextProps) | ||
|
||
This callback hook fires if the memoized component updates. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
import React from 'react' | ||
import { mount } from 'enzyme' | ||
import memo from '../memo' | ||
|
||
describe('memo', () => { | ||
describe('Wrapping', () => { | ||
test('Can wrap a SFC', () => { | ||
const Compo = props => <div {...props} /> | ||
const MemoCompo = memo(Compo) | ||
|
||
const wrapper = mount(<MemoCompo>Hello</MemoCompo>) | ||
|
||
expect(Compo).not.toEqual(MemoCompo) | ||
expect(wrapper.text()).toBe('Hello') | ||
}) | ||
|
||
test('Can wrap a React.PureComponent', () => { | ||
class Compo extends React.PureComponent { | ||
render() { | ||
return <div {...this.props} /> | ||
} | ||
} | ||
const MemoCompo = memo(Compo) | ||
|
||
const wrapper = mount(<MemoCompo>Hello</MemoCompo>) | ||
|
||
expect(Compo).not.toEqual(MemoCompo) | ||
expect(wrapper.text()).toBe('Hello') | ||
}) | ||
|
||
test('Can wrap a React.Component', () => { | ||
class Compo extends React.Component { | ||
render() { | ||
return <div {...this.props} /> | ||
} | ||
} | ||
const MemoCompo = memo(Compo) | ||
|
||
const wrapper = mount(<MemoCompo>Hello</MemoCompo>) | ||
|
||
expect(Compo).not.toEqual(MemoCompo) | ||
expect(wrapper.text()).toBe('Hello') | ||
}) | ||
}) | ||
|
||
describe('Statics', () => { | ||
test('Hoists statics from the wrapped component', () => { | ||
const Compo = props => <div {...props} /> | ||
Compo.Sub = props => <div {...props} /> | ||
const MemoCompo = memo(Compo) | ||
|
||
const wrapper = mount(<MemoCompo.Sub>Hello</MemoCompo.Sub>) | ||
|
||
expect(wrapper.text()).toBe('Hello') | ||
}) | ||
|
||
test('Uses displayName, if defined', () => { | ||
const Compo = props => <div {...props} /> | ||
Compo.displayName = 'Hello' | ||
const MemoCompo = memo(Compo) | ||
|
||
const wrapper = mount(<MemoCompo>Hello</MemoCompo>) | ||
|
||
expect(wrapper.find('Hello').length).toBeTruthy() | ||
}) | ||
}) | ||
|
||
describe('Memoize', () => { | ||
test('Does not re-render component if prop does not change', () => { | ||
const spy = jest.fn() | ||
const Compo = props => <div {...props} /> | ||
const MemoCompo = memo(Compo, null, { | ||
componentDidUpdate: spy, | ||
}) | ||
MemoCompo.displayName = 'Memo' | ||
|
||
const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>) | ||
const initialComponent = wrapper.find('Memo') | ||
|
||
expect(wrapper.text()).toBe('Hello') | ||
expect(spy).toHaveBeenCalledTimes(1) | ||
|
||
wrapper.setProps({ title: 'Hello' }) | ||
wrapper.setProps({ children: 'Hello' }) | ||
expect(spy).toHaveBeenCalledTimes(1) | ||
|
||
expect(initialComponent).toEqual(wrapper.find('Memo')) | ||
}) | ||
|
||
test('Re-renders component on prop change', () => { | ||
const spy = jest.fn() | ||
const Compo = props => <div {...props} /> | ||
const MemoCompo = memo(Compo, null, { | ||
componentDidUpdate: spy, | ||
}) | ||
MemoCompo.displayName = 'Memo' | ||
|
||
const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>) | ||
const initialComponent = wrapper.find('Memo') | ||
|
||
expect(wrapper.text()).toBe('Hello') | ||
expect(spy).toHaveBeenCalledTimes(1) | ||
|
||
wrapper.setProps({ title: 'Hello' }) | ||
wrapper.setProps({ title: 'There' }) | ||
expect(spy).toHaveBeenCalledTimes(2) | ||
|
||
expect(initialComponent).not.toEqual(wrapper.find('Memo')) | ||
}) | ||
|
||
test('Re-renders component if new prop value existed previously', () => { | ||
const spy = jest.fn() | ||
const Compo = props => <div {...props} /> | ||
const MemoCompo = memo(Compo, null, { | ||
componentDidUpdate: spy, | ||
}) | ||
|
||
const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>) | ||
|
||
expect(wrapper.text()).toBe('Hello') | ||
expect(spy).toHaveBeenCalledTimes(1) | ||
|
||
wrapper.setProps({ title: 'There' }) | ||
expect(spy).toHaveBeenCalledTimes(2) | ||
|
||
wrapper.setProps({ title: 'There' }) | ||
expect(spy).toHaveBeenCalledTimes(2) | ||
|
||
wrapper.setProps({ title: 'Hello' }) | ||
expect(spy).toHaveBeenCalledTimes(3) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import * as React from 'react' | ||
import { ComponentType } from 'react' | ||
import hoistNonReactStatics from './hoistNonReactStatics' | ||
import shallowEqual, { CompareFunction } from './shallowEqual' | ||
import wrapComponentName from './wrapComponentName' | ||
import { isDefined, isFunction, noop } from './utils' | ||
|
||
const defaultLifeCycleHooks = { | ||
componentDidUpdate: noop, | ||
} | ||
|
||
// Enhancement + polyfill for React.memo (v16.6) | ||
// https://reactjs.org/docs/react-api.html#reactmemo | ||
export function memo<T>( | ||
Component: ComponentType<T>, | ||
areEqual: CompareFunction = shallowEqual, | ||
lifecycleHooks: any = defaultLifeCycleHooks, | ||
): ComponentType<T> { | ||
// Cache the initial props + component | ||
let prevProps = {} | ||
let memoizedComponent: JSX.Element | undefined | ||
|
||
// Merge/extract options | ||
const lifecycles = { ...defaultLifeCycleHooks, ...lifecycleHooks } | ||
const { componentDidUpdate } = lifecycles | ||
const shouldComponentUpdate = isFunction(areEqual) ? areEqual : shallowEqual | ||
|
||
const wrappedComponent = nextProps => { | ||
// shouldComponentUpdate test for prop changes | ||
if ( | ||
isDefined(memoizedComponent) && | ||
shouldComponentUpdate(prevProps, nextProps) | ||
) { | ||
return memoizedComponent | ||
} | ||
|
||
// Update the prop cache | ||
prevProps = nextProps | ||
// Update the memozied component | ||
memoizedComponent = <Component {...nextProps} /> | ||
|
||
// Call the componentDidUpdate hook | ||
componentDidUpdate(prevProps, nextProps) | ||
|
||
return memoizedComponent | ||
} | ||
|
||
wrappedComponent.displayName = wrapComponentName(Component, 'memo') | ||
|
||
return hoistNonReactStatics(wrappedComponent, Component) | ||
} | ||
|
||
export default memo |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
export type CompareFunction = ( | ||
prev: { [key: string]: any }, | ||
next: { [key: string]: any }, | ||
) => boolean | ||
|
||
export const shallowEqual = ( | ||
prev: { [key: string]: any }, | ||
next: { [key: string]: any }, | ||
): boolean => { | ||
for (let key in next) { | ||
if (next[key] !== prev[key]) return false | ||
} | ||
|
||
return true | ||
} | ||
|
||
export default shallowEqual |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters