Skip to content

Commit

Permalink
Merge e2b3123 into 46779e8
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Quach committed Mar 11, 2019
2 parents 46779e8 + e2b3123 commit 9574ece
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 2 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Expand Up @@ -12,6 +12,7 @@
- [`getWindowFromComponent`](getwindowfromcomponent.md)
- [`hoistNonReactStatics`](hoistnonreactstatics.md)
- [`isReactComponent`](isreactcomponent.md)
- [`memo`](memo.md)
- [`perf`](perf.md)
- [`renderSpy`](renderSpy.md)
- [`withSafeSetState`](withsafesetstate.md)
Expand Down
40 changes: 40 additions & 0 deletions docs/memo.md
@@ -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.
133 changes: 133 additions & 0 deletions src/__tests__/memo.test.js
@@ -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)
})
})
})
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -16,6 +16,7 @@ export { default as hoistNonReactStatics } from './hoistNonReactStatics'
export { default as isPropValid } from './isPropValid'
export { default as isReactComponent } from './isReactComponent'
export { default as isType } from './isType'
export { default as memo } from './memo'
export { default as perf } from './perf'
export { default as reactVersion } from './reactVersion'
export { default as renderSpy } from './renderSpy'
Expand Down
53 changes: 53 additions & 0 deletions src/memo.tsx
@@ -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
17 changes: 17 additions & 0 deletions src/shallowEqual.ts
@@ -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
8 changes: 6 additions & 2 deletions src/utils.ts
Expand Up @@ -22,11 +22,11 @@ export function isFunction<T>(value: unknown): value is Function {
return typeOf(value, 'function')
}

export function isNumber<T>(value: unknown): value is Number {
export function isNumber<T>(value: unknown): value is number {
return typeOf(value, 'number')
}

export function isString<T>(value: unknown): value is String {
export function isString<T>(value: unknown): value is string {
return typeOf(value, 'string')
}

Expand All @@ -51,3 +51,7 @@ export function createUniqueIndexFactory(start: number = 1) {
let index = typeof start === 'number' ? start : 1
return (): number => index++
}

export function noop() {
return undefined
}

0 comments on commit 9574ece

Please sign in to comment.