Skip to content

Commit

Permalink
fix(subcomponent): Correct bug with subComponent remounting between e…
Browse files Browse the repository at this point in the history
…ach render
  • Loading branch information
Luc Merceron committed May 1, 2017
1 parent bc48394 commit 97764a7
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 55 deletions.
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,24 @@ npm install --save react-size-fetcher

SizeFetcher is a [Higher Order Component](https://facebook.github.io/react/docs/higher-order-components.html); by giving it a component, it will return an enhanced component.
```javascript
const EnhancedComponent = SizeFetcher(InitialComponent, [options])
const EnhancedComponent = SizeFetcher(ComponentToObserve, [options])
```
The enhanced component is special, it will be a copy of the given component but will accept a new prop called `sizeChange`.
```javascript
<EnhancedComponent {/* InitialComponent Props */} sizeChange={size => console.log('Size Changed: ', size)} />
<EnhancedComponent {/* ComponentToObserve Props */} sizeChange={size => console.log('Size Changed: ', size)} />
```
sizeChange needs to be a function with one argument, it will be called with an Object representing the size of the component.
sizeChange is a function with one argument, it will be called with an `Object` representing the size of the component when the component did mount and when its size change.
```javascript
// Size Changed: { clientWidth: 120, clientHeight: 230, scrollWidth: 120, scrollHeight: 430 }
```

### Arguments
* Component (React Component): This can be a [React Functional or Class Component](https://facebook.github.io/react/docs/components-and-props.html#functional-and-class-components).
* [options] (Object): Available options:
* [noComparison] (Boolean): Default value: false. This option allow you to bypass SizeFetcher optimization. SizeFetcher will compare all the size and not call `sizeChange` if the size did not change between two updates. `const EnhancedComponent = SizeFetcher(ComponentToObserve, { noComparison: true})`
* [noComparison] (Boolean): Default value: false. This option allow you to bypass SizeFetcher optimization. SizeFetcher will compare all the size and not call `sizeChange` if the size did not change between two updates.
`const EnhancedComponent = SizeFetcher(ComponentToObserve, { noComparison: true})`
* [shallow] (Boolean): Default value: false. This option allow you to optimize SizeFetcher if your ComponentToObserve does not contain sub-component that can change in size.
`const EnhancedComponent = SizeFetcher(ComponentToObserve, { shallow: true})`
### Returns
A Higher-Order React Component that inherit from your initial component and take one more props named `sizeChange`. sizeChange is suceptible to be called when the component receives new props, updates its state or when the window resize.

Expand All @@ -47,28 +50,28 @@ class AwareComponent extends React.Component {
super()

this.state = {
subComponentSize = null
subComponentSize: null
}
}
render() {
const { subComponentSize } = this.state

return (
<div>
<h1>The size of the sub component is {JSON.stringify(subComponentSize, null, 2)</h1>
<EnhancedComponent sizeChange={size => this.setState(size)} {/* ComponentToObserve usual props */}/>
<h1>The size of the sub component is {JSON.stringify(subComponentSize, null, 2)}</h1>
<EnhancedComponent sizeChange={size => this.setState(size)} {/* ComponentToObserve usual props */} />
</div>
)
}
}
```

You can also enhance directly your ComponentToObserve by exporting the Higher Order Component directly in your *ComponentToObserve.js* file:
You can also enhance directly your ComponentToObserve by exporting if with SizeFetcher in your *ComponentToObserve.js* file:

```
export default SizeFetcher(ComponentToObserve)
```
or with decorator
or with a decorator
```
@SizeFetcher
class ComponentToObserve extends React.Component {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"report-coverage": "cat ./coverage/lcov.info | codecov",
"test": "jest",
"test:watch": "jest --watch",
"build": "babel --out-dir dist --ignore *.test.js src",
"build": "babel --out-dir dist --ignore *.test.js,./src/test/* src",
"prebuild": "rimraf dist",
"semantic-release": "semantic-release pre && npm publish && semantic-release post"
},
Expand Down
24 changes: 4 additions & 20 deletions src/EnhanceInnerComponent.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
import React from 'react'

import NormalizeComponent from './utils/NormalizeComponent'

const getDisplayName = wrappedComponent => wrappedComponent.displayName || wrappedComponent.name
const isStateless = component => !component.render && !(component.prototype && component.prototype.render)

const EnhanceInnerComponent = InnerComponent => {
const component = InnerComponent
let ComposedComponent = component

// Managing component without state (functional component)
if (isStateless(ComposedComponent)) {
if (typeof component !== 'function') {
warning('SizeFetcher has been called with neither a React Functional or Class Component')
return () => null
}
ComposedComponent = class extends React.Component {
render() {
return component(this.props)
}
}
ComposedComponent.displayName = getDisplayName(component)
}
const ComposedComponent = NormalizeComponent(InnerComponent)

// No verification here as React should have already analyzed the component
class EnhancerInnerComponent extends ComposedComponent {
componentDidMount() {
if (super.componentDidMount) super.componentDidMount()
}
componentDidUpdate() {
if (super.componentDidUpdate) super.componentDidUpdate()

Expand Down
41 changes: 17 additions & 24 deletions src/SizeFetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,20 @@ import PropTypes from 'prop-types'

import warning from './utils/warning'
import EnhanceInnerComponent from './EnhanceInnerComponent'
import NormalizeComponent from './utils/NormalizeComponent'

const getDisplayName = wrappedComponent => wrappedComponent.displayName || wrappedComponent.name
const isStateless = component => !component.render && !(component.prototype && component.prototype.render)

/*
* Size Inversion Inheritence Higher Order Component
* This component is usefull when you need a transparant way for knowing the size of a sub component
* It will call the sizeChange function when the size of the sub component is first known and then everytime it changes
*/
const SizeFetcher = (SubComponent, options = { noComparison: false }) => {
const component = SubComponent
let ComposedComponent = component

// Managing component without state (functional component)
if (isStateless(ComposedComponent)) {
if (typeof component !== 'function') {
warning('SizeFetcher has been called with neither a React Functional or Class Component')
return () => null
}
ComposedComponent = class extends React.Component {
render() {
return component(this.props)
}
}
ComposedComponent.displayName = getDisplayName(component)
}
const SizeFetcher = (SubComponent, options = { noComparison: false, shallow: false }) => {
const ComposedComponent = NormalizeComponent(SubComponent)
if (!ComposedComponent) return () => null

let registeredType = {}

class Enhancer extends ComposedComponent {
componentDidMount() {
Expand Down Expand Up @@ -59,6 +47,7 @@ const SizeFetcher = (SubComponent, options = { noComparison: false }) => {
this.privateRegisterComponentInfos(clientHeight, clientWidth, scrollHeight, scrollWidth)
}
privateHandleSizeMayHaveChanged() {
if (!this.comp) return
const { clientHeight, clientWidth, scrollHeight, scrollWidth } = this.comp

if (options.noComparison ||
Expand Down Expand Up @@ -103,9 +92,11 @@ const SizeFetcher = (SubComponent, options = { noComparison: false }) => {
} else if (child && typeof child.type === 'function') {
// Forth case: The children is actually an InnerComponent (A composed component)
// Enhance the inner component type so we can detect when it updates
const EnhancedInner = EnhanceInnerComponent(child.type)
if (!registeredType[child.type.displayName]) registeredType[child.type.displayName] = EnhanceInnerComponent(child.type)
const EnhancedInner = registeredType[child.type.displayName]
// const EnhancedInner = EnhanceInnerComponent(child.type)
// Add the callback function to the props of the component
const newProps = Object.assign({}, child.props, { sizeMayChange: () => this.privateHandleSizeMayHaveChanged() })
const newProps = Object.assign({}, child.props, { key: 0, sizeMayChange: () => this.privateHandleSizeMayHaveChanged() })

const EnhancerInnerElement = React.createElement(EnhancedInner, newProps)
return EnhancerInnerElement
Expand All @@ -115,12 +106,14 @@ const SizeFetcher = (SubComponent, options = { noComparison: false }) => {
}

render() {
this.elementsTree = super.render()
this.enhancedChildren = this.privateEnhanceChildren(this.elementsTree.props.children)
const elementsTree = super.render()

const newChildren = options.shallow ? elementsTree.props.children : this.privateEnhanceChildren(elementsTree.props.children)
// Here thanks to II, we can add a ref without the subComponent noticing
const newProps = Object.assign({}, this.elementsTree.props, { ref: comp => (this.comp = comp) })
const newProps = Object.assign({}, elementsTree.props, { ref: comp => (this.comp = comp) })
// Create a new component from SubComponent render with new props
const newElementsTree = React.cloneElement(this.elementsTree, newProps, this.enhancedChildren)
const newElementsTree = React.cloneElement(elementsTree, newProps, newChildren)

return newElementsTree
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/test/DynamicComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ class DynamicComponent extends React.Component {
dynamicState: "Default",
}
}
componentDidUpdate() {
const { lifeCycleCallback } = this.props

lifeCycleCallback('didUpdate')
}
componentDidMount() {
const { lifeCycleCallback } = this.props

lifeCycleCallback('didMount')
this.setState({
dynamicState: "Dynamic",
})
Expand All @@ -20,7 +28,7 @@ class DynamicComponent extends React.Component {

return (
<div className="simple-dynamic-component">
<h1>A Random Title</h1>
<h1><span>A Random Title</span></h1>
<h2>A Random Sub-Title</h2>
<p>{content}</p>
<p>{dynamicState}</p>
Expand Down
20 changes: 20 additions & 0 deletions src/test/SizeFetcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe('Index', () => {
const EnhancedComposedNormalComponent = SizeFetcher(ComposedNormalComponent, { noComparison: true })
const EnhancedComposedFunctionalComponent = SizeFetcher(ComposedFunctionalComponent)
const EnhancedComposedDynamicComponent = SizeFetcher(ComposedDynamicComponent, { noComparison: true })
const EnhancedComposedDynamicShallowComponent = SizeFetcher(ComposedDynamicComponent, { noComparison: true, shallow: true })

const composedNormalComponentProps = {
sizeChange: jest.fn(),
Expand All @@ -129,10 +130,17 @@ describe('Index', () => {
content: "ComposedNormal",
subContent: "Normal",
}
const composedDynamicShallowComponentProps = {
sizeChange: jest.fn(),
lifeCycleCallback: jest.fn(),
content: "ComposedNormal",
subContent: "Normal",
}

const WrapperEnhancedComposedNormalComponent = mount(<EnhancedComposedNormalComponent {...composedNormalComponentProps} />)
const WrapperEnhancedComposedFunctionalComponent = mount(<EnhancedComposedFunctionalComponent {...composedFunctionalComponentProps} />)
const WrapperEnhancedComposedDynamicComponent = mount(<EnhancedComposedDynamicComponent {...composedDynamicComponentProps} />)
const WrapperEnhancedComposedShallowDynamicComponent = mount(<EnhancedComposedDynamicShallowComponent {...composedDynamicShallowComponentProps} />)

it('should render composed components correctly', () => {
expect(WrapperEnhancedComposedNormalComponent.find('.composed-normal-component').node).toBeDefined()
Expand Down Expand Up @@ -174,5 +182,17 @@ describe('Index', () => {
expect(composedDynamicComponentProps.sizeChange.mock.calls.length).toEqual(4)
expect(WrapperEnhancedComposedDynamicComponent.html()).toContain("Dynamic")
})
it('should not call sizeChange when dynamic sub-component changes by itself on shallowed component', () => {
expect(composedDynamicShallowComponentProps.sizeChange.mock.calls.length).toEqual(3)
expect(WrapperEnhancedComposedShallowDynamicComponent.html()).toContain("Dynamic")
})
it('should not remount sub element state when SizeFetcher component update', () => {
WrapperEnhancedComposedDynamicComponent.setProps({ content: 'New ComposedNormal'})
expect(WrapperEnhancedComposedDynamicComponent.html()).toContain('New ComposedNormal')
// didMount called only once: [ [ 'didMount' ], [ 'didUpdate' ], [ 'didUpdate' ] ]
expect(composedDynamicComponentProps.lifeCycleCallback.mock.calls[0]
.filter(lifeCycle => lifeCycle === 'didMount').length).toEqual(1)
expect(composedDynamicComponentProps.sizeChange.mock.calls.length).toEqual(5)
})
})
})
29 changes: 29 additions & 0 deletions src/utils/NormalizeComponent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react'

import warning from './warning'

const getDisplayName = wrappedComponent => wrappedComponent.displayName || wrappedComponent.name
const isStateless = component => !component.render && !(component.prototype && component.prototype.render)

const NormalizeComponent = SchrodingerComponent => {
const component = SchrodingerComponent
let ComposedComponent = component

// Managing component without state (functional component)
if (isStateless(ComposedComponent)) {
if (typeof component !== 'function') {
warning('SizeFetcher has been called with neither a React Functional or Class Component')
return null
}
ComposedComponent = class extends React.Component {
render() {
return component(this.props)
}
}
ComposedComponent.displayName = getDisplayName(component)
}

return ComposedComponent
}

export default NormalizeComponent
1 change: 1 addition & 0 deletions src/utils/warning.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export default function warning(message) {
/* istanbul ignore next */
/* eslint-disable no-console */
if (typeof console !== 'undefined' && typeof console.error === 'function') {
console.error(message)
Expand Down

0 comments on commit 97764a7

Please sign in to comment.