Skip to content

Commit 49f180f

Browse files
committed
Create LazyLoadComponent to lazy load generic components/elements
1 parent 21d7d10 commit 49f180f

File tree

5 files changed

+405
-303
lines changed

5 files changed

+405
-303
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from 'react';
2+
import ReactDOM from 'react-dom';
3+
import { PropTypes } from 'prop-types';
4+
5+
class LazyLoadComponent extends React.Component {
6+
constructor(props) {
7+
super(props);
8+
9+
const { afterLoad, beforeLoad, visibleByDefault } = this.props;
10+
11+
this.state = {
12+
visible: visibleByDefault
13+
};
14+
15+
if (visibleByDefault) {
16+
beforeLoad();
17+
afterLoad();
18+
}
19+
}
20+
21+
componentDidMount() {
22+
this.updateVisibility();
23+
}
24+
25+
componentDidUpdate(prevProps, prevState) {
26+
if (prevState.visible) {
27+
return;
28+
}
29+
30+
if (this.state.visible) {
31+
this.props.afterLoad();
32+
}
33+
34+
this.updateVisibility();
35+
}
36+
37+
getPlaceholderBoundingBox(scrollPosition = this.props.scrollPosition) {
38+
const boundingRect = this.placeholder.getBoundingClientRect();
39+
const style = ReactDOM.findDOMNode(this.placeholder).style;
40+
const margin = {
41+
left: parseInt(style.getPropertyValue('margin-left'), 10) || 0,
42+
top: parseInt(style.getPropertyValue('margin-top'), 10) || 0
43+
};
44+
45+
return {
46+
bottom: scrollPosition.y + boundingRect.bottom + margin.top,
47+
left: scrollPosition.x + boundingRect.left + margin.left,
48+
right: scrollPosition.x + boundingRect.right + margin.left,
49+
top: scrollPosition.y + boundingRect.top + margin.top
50+
};
51+
}
52+
53+
isPlaceholderInViewport() {
54+
if (!this.placeholder) {
55+
return false;
56+
}
57+
58+
const { scrollPosition, threshold } = this.props;
59+
const boundingBox = this.getPlaceholderBoundingBox(scrollPosition);
60+
const viewport = {
61+
bottom: scrollPosition.y + window.innerHeight,
62+
left: scrollPosition.x,
63+
right: scrollPosition.x + window.innerWidth,
64+
top: scrollPosition.y
65+
};
66+
67+
return Boolean(viewport.top - threshold <= boundingBox.bottom &&
68+
viewport.bottom + threshold >= boundingBox.top &&
69+
viewport.left - threshold <= boundingBox.right &&
70+
viewport.right + threshold >= boundingBox.left);
71+
}
72+
73+
updateVisibility() {
74+
if (this.state.visible || !this.isPlaceholderInViewport()) {
75+
return;
76+
}
77+
78+
this.props.beforeLoad();
79+
80+
this.setState({
81+
visible: true
82+
});
83+
}
84+
85+
getPlaceholder() {
86+
const { className, height, placeholder, style, width } = this.props;
87+
88+
if (placeholder) {
89+
return React.cloneElement(placeholder,
90+
{ ref: el => this.placeholder = el });
91+
}
92+
93+
return (
94+
<span className={className}
95+
ref={el => this.placeholder = el}
96+
style={{ height, width, ...style }}>
97+
</span>
98+
);
99+
}
100+
101+
render() {
102+
return this.state.visible ?
103+
this.props.children :
104+
this.getPlaceholder();
105+
}
106+
}
107+
108+
LazyLoadComponent.propTypes = {
109+
scrollPosition: PropTypes.shape({
110+
x: PropTypes.number.isRequired,
111+
y: PropTypes.number.isRequired
112+
}).isRequired,
113+
afterLoad: PropTypes.func,
114+
beforeLoad: PropTypes.func,
115+
className: PropTypes.string,
116+
height: PropTypes.number,
117+
placeholder: PropTypes.element,
118+
threshold: PropTypes.number,
119+
visibleByDefault: PropTypes.bool,
120+
width: PropTypes.number
121+
};
122+
123+
LazyLoadComponent.defaultProps = {
124+
afterLoad: () => ({}),
125+
beforeLoad: () => ({}),
126+
className: '',
127+
height: 0,
128+
placeholder: null,
129+
threshold: 100,
130+
visibleByDefault: false,
131+
width: 0
132+
};
133+
134+
export default LazyLoadComponent;
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import React from 'react';
2+
import ReactTestUtils from 'react-dom/test-utils';
3+
import { configure, mount } from 'enzyme';
4+
import Adapter from 'enzyme-adapter-react-16';
5+
6+
import LazyLoadComponent from './LazyLoadComponent.jsx';
7+
8+
configure({ adapter: new Adapter() });
9+
10+
const {
11+
scryRenderedDOMComponentsWithTag
12+
} = ReactTestUtils;
13+
14+
describe('LazyLoadComponent', function() {
15+
function renderLazyLoadComponent({
16+
afterLoad = () => null,
17+
beforeLoad = () => null,
18+
placeholder = null,
19+
scrollPosition = {x: 0, y: 0},
20+
style = {},
21+
visibleByDefault = false
22+
} = {}) {
23+
return mount(
24+
<LazyLoadComponent
25+
afterLoad={afterLoad}
26+
beforeLoad={beforeLoad}
27+
placeholder={placeholder}
28+
scrollPosition={scrollPosition}
29+
style={style}
30+
visibleByDefault={visibleByDefault}>
31+
<p>Lorem ipsum</p>
32+
</LazyLoadComponent>
33+
);
34+
}
35+
36+
function simulateScroll(lazyLoadComponent, offsetX = 0, offsetY = 0) {
37+
const myMock = jest.fn();
38+
39+
myMock.mockReturnValue({
40+
bottom: -offsetY,
41+
height: 0,
42+
left: -offsetX,
43+
right: -offsetX,
44+
top: -offsetY,
45+
width: 0
46+
});
47+
48+
lazyLoadComponent.instance().placeholder.getBoundingClientRect = myMock;
49+
50+
lazyLoadComponent.setProps({
51+
scrollPosition: {x: offsetX, y: offsetY}
52+
});
53+
}
54+
55+
function expectParagraphs(wrapper, numberOfParagraphs) {
56+
const p = scryRenderedDOMComponentsWithTag(wrapper.instance(), 'p');
57+
58+
expect(p.length).toEqual(numberOfParagraphs);
59+
}
60+
61+
function expectPlaceholders(wrapper, numberOfPlaceholders, placeholderTag = 'span') {
62+
const placeholder = scryRenderedDOMComponentsWithTag(wrapper.instance(), placeholderTag);
63+
64+
expect(placeholder.length).toEqual(numberOfPlaceholders);
65+
}
66+
67+
it('renders the default placeholder when it\'s not in the viewport', function() {
68+
const lazyLoadComponent = renderLazyLoadComponent({
69+
style: {marginTop: 100000}
70+
});
71+
72+
expectParagraphs(lazyLoadComponent, 0);
73+
expectPlaceholders(lazyLoadComponent, 1);
74+
});
75+
76+
it('renders the prop placeholder when it\'s not in the viewport', function() {
77+
const style = {marginTop: 100000};
78+
const placeholder = (
79+
<strong style={style}></strong>
80+
);
81+
const lazyLoadComponent = renderLazyLoadComponent({
82+
placeholder,
83+
style
84+
});
85+
86+
expectParagraphs(lazyLoadComponent, 0);
87+
expectPlaceholders(lazyLoadComponent, 1, 'strong');
88+
});
89+
90+
it('renders the image when it\'s in the viewport', function() {
91+
const lazyLoadComponent = renderLazyLoadComponent();
92+
93+
expectParagraphs(lazyLoadComponent, 1);
94+
expectPlaceholders(lazyLoadComponent, 0);
95+
});
96+
97+
it('renders the image when it appears in the viewport', function() {
98+
const offset = 100000;
99+
const lazyLoadComponent = renderLazyLoadComponent({
100+
style: {marginTop: offset}
101+
});
102+
103+
simulateScroll(lazyLoadComponent, 0, offset);
104+
105+
expectParagraphs(lazyLoadComponent, 1);
106+
expectPlaceholders(lazyLoadComponent, 0);
107+
});
108+
109+
it('renders the image when it appears in the viewport horizontally', function() {
110+
const offset = 100000;
111+
const lazyLoadComponent = renderLazyLoadComponent({
112+
style: {marginLeft: offset}
113+
});
114+
115+
simulateScroll(lazyLoadComponent, offset, 0);
116+
117+
expectParagraphs(lazyLoadComponent, 1);
118+
expectPlaceholders(lazyLoadComponent, 0);
119+
});
120+
121+
it('renders the image when it\'s not in the viewport but visibleByDefault is true', function() {
122+
const lazyLoadComponent = renderLazyLoadComponent({
123+
style: {marginTop: 100000},
124+
visibleByDefault: true
125+
});
126+
127+
expectParagraphs(lazyLoadComponent, 1);
128+
expectPlaceholders(lazyLoadComponent, 0);
129+
});
130+
131+
it('doesn\'t trigger beforeLoad when the image is not the viewport', function() {
132+
const beforeLoad = jest.fn();
133+
const lazyLoadComponent = renderLazyLoadComponent({
134+
beforeLoad,
135+
style: {marginTop: 100000}
136+
});
137+
138+
expect(beforeLoad).toHaveBeenCalledTimes(0);
139+
});
140+
141+
it('triggers beforeLoad when the image is in the viewport', function() {
142+
const beforeLoad = jest.fn();
143+
const lazyLoadComponent = renderLazyLoadComponent({
144+
beforeLoad
145+
});
146+
147+
expect(beforeLoad).toHaveBeenCalledTimes(1);
148+
});
149+
150+
it('triggers beforeLoad when the image appears in the viewport', function() {
151+
const beforeLoad = jest.fn();
152+
const offset = 100000;
153+
const lazyLoadComponent = renderLazyLoadComponent({
154+
beforeLoad,
155+
style: {marginTop: offset}
156+
});
157+
158+
simulateScroll(lazyLoadComponent, 0, offset);
159+
160+
expect(beforeLoad).toHaveBeenCalledTimes(1);
161+
});
162+
163+
it('triggers beforeLoad when visibleByDefault is true', function() {
164+
const beforeLoad = jest.fn();
165+
const offset = 100000;
166+
const lazyLoadComponent = renderLazyLoadComponent({
167+
beforeLoad,
168+
style: {marginTop: offset},
169+
visibleByDefault: true
170+
});
171+
172+
expect(beforeLoad).toHaveBeenCalledTimes(1);
173+
});
174+
175+
it('doesn\'t trigger afterLoad when the image is not the viewport', function() {
176+
const afterLoad = jest.fn();
177+
const lazyLoadComponent = renderLazyLoadComponent({
178+
afterLoad,
179+
style: {marginTop: 100000}
180+
});
181+
182+
expect(afterLoad).toHaveBeenCalledTimes(0);
183+
});
184+
185+
it('triggers afterLoad when the image is in the viewport', function() {
186+
const afterLoad = jest.fn();
187+
const lazyLoadComponent = renderLazyLoadComponent({
188+
afterLoad
189+
});
190+
191+
expect(afterLoad).toHaveBeenCalledTimes(1);
192+
});
193+
194+
it('triggers afterLoad when the image appears in the viewport', function() {
195+
const afterLoad = jest.fn();
196+
const offset = 100000;
197+
const lazyLoadComponent = renderLazyLoadComponent({
198+
afterLoad,
199+
style: {marginTop: offset}
200+
});
201+
202+
simulateScroll(lazyLoadComponent, 0, offset);
203+
204+
expect(afterLoad).toHaveBeenCalledTimes(1);
205+
});
206+
207+
it('triggers afterLoad when visibleByDefault is true', function() {
208+
const afterLoad = jest.fn();
209+
const offset = 100000;
210+
const lazyLoadComponent = renderLazyLoadComponent({
211+
afterLoad,
212+
style: {marginTop: offset},
213+
visibleByDefault: true
214+
});
215+
216+
expect(afterLoad).toHaveBeenCalledTimes(1);
217+
});
218+
});

0 commit comments

Comments
 (0)