├── .babelrc ├── .flowconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── example ├── App.js └── index.js ├── jest.config.js ├── package.json ├── src ├── _.test.js ├── index.d.ts ├── index.html ├── index.js ├── index.test.js ├── types.js └── utils │ ├── convertOffsetToBounds.js │ ├── eventListenerOptions.js │ ├── getElementBounds.js │ ├── getElementBounds.test.js │ ├── getViewportBounds.js │ ├── getViewportBounds.test.js │ ├── isBackCompatMode.js │ ├── isElementInViewport.js │ └── isElementInViewport.test.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es5": { 4 | "presets": ["@babel/env", "@babel/react", "@babel/flow"], 5 | "plugins": ["@babel/plugin-proposal-class-properties"] 6 | }, 7 | "esnext": { 8 | "presets": [["@babel/env", {"modules": false}], "@babel/react", "@babel/flow"], 9 | "plugins": ["@babel/plugin-proposal-class-properties"] 10 | }, 11 | "test": { 12 | "presets": ["@babel/env", "@babel/react", "@babel/flow"], 13 | "plugins": ["@babel/plugin-proposal-class-properties"] 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | unsafe.enable_getters_and_setters=true 11 | 12 | [strict] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | 5 | script: yarn run ci -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 1.2.0 4 | 5 | - added the ability to specify the `scrollContainer` ([#7](https://github.com/jameslnewell/react-lazily-render/pull/7)) 6 | - updated `devDependencies` that had security issues 7 | - added `react` as a `peerDependency` 8 | - added the `module` field in `package.json` 9 | 10 | ## 1.1.0 11 | 12 | - added the ability to change the wrapper component ([#6](https://github.com/jameslnewell/react-lazily-render/pull/6)) 13 | 14 | ## 1.0.2 15 | 16 | - fix scroll detection in some rare circumstances ([#5](https://github.com/jameslnewell/react-lazily-render/pull/5)) 17 | 18 | ## 1.0.1 19 | 20 | - implemented a few micro optimizations 21 | - remove the unnecessary library 22 | - cache the container 23 | 24 | ## 1.0.0 25 | 26 | - major bump to use versioning properly 27 | - use passive event listeners for a minor perf boost 28 | 29 | ## 0.0.8 30 | 31 | - fix types 32 | 33 | ## 0.0.7 34 | 35 | - use `raf-schd` instead of `debounce` 36 | - added `offset` prop 37 | - added `placeholder` and `content` props 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-lazily-render 2 | 3 | [![Build Status](https://travis-ci.org/jameslnewell/react-lazily-render.svg?branch=master)](https://travis-ci.org/jameslnewell/react-lazily-render) 4 | 5 | Lazily render react components. 6 | 7 | Delay mounting expensive components until a placeholder component has been scrolled into view. 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install --save react-lazily-render 13 | ``` 14 | 15 | ## Usage 16 | 17 | [Example](https://jameslnewell.github.io/react-lazily-render) ([source](https://github.com/jameslnewell/react-lazily-render/blob/master/example/App.js#L8)) 18 | 19 | ```js 20 | import React from 'react'; 21 | import LazilyRender from 'react-lazily-render'; 22 | 23 |
24 | ...lots of content... 25 | } 27 | content={} 28 | /> 29 | ...lots of content... 30 | 31 | {render => render 32 | ? 33 | : 34 | } 35 | 36 | ...lots of content... 37 |
38 | 39 | ``` 40 | 41 | ## API 42 | 43 | ### Properties 44 | 45 | #### className 46 | 47 | > `string` 48 | 49 | The `className` applied to the wrapping element. 50 | 51 | #### component 52 | 53 | > `string | React.ComponentClass` 54 | 55 | The wrapping component. 56 | 57 | e.g. 58 | ```js 59 | 60 | 61 | ``` 62 | 63 | ### offset 64 | 65 | > `number | {top?: number, right?: number, bottom?: number, left?: number}` 66 | 67 | An offset applied to the element for calculating whether the component has been scrolled into view. 68 | 69 | You can specify individual values for each side, or a single value used for all sides. 70 | 71 | #### placeholder 72 | 73 | > `React.Node` 74 | 75 | Rendered when the component hasn't been scrolled into view. 76 | 77 | #### content 78 | 79 | > `React.Node` 80 | 81 | Rendered when the component has been scrolled into view. 82 | 83 | #### children 84 | 85 | > `(render: boolean) => React.Node` 86 | 87 | Called to render something depending on whether the component has been scrolled into view. 88 | 89 | #### onRender 90 | 91 | > `() => void` 92 | 93 | Called when the component becomes visible for the first time. 94 | 95 | #### scrollContainer 96 | 97 | > `HTMLElement | undefined` 98 | 99 | The container which `react-lazily-render` listens to for scroll events. 100 | 101 | This property can be used in a scenario where you want to specify your own scroll container - e.g. if the component you are rendering is asynchronously added to the DOM. -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LazilyRender from 'react-lazily-render'; 3 | 4 | function Content({isWindow, offset, onRender}) { 5 | return ( 6 |
7 |
8 | 9 | {render => render 10 | ?
loaded!
11 | :
loading...
12 | } 13 |
14 |
15 |
16 | ); 17 | } 18 | 19 | export default class App extends React.Component { 20 | 21 | state = { 22 | isWindow: true, 23 | isRendered: false, 24 | offset: undefined 25 | }; 26 | 27 | 28 | handleMountContainer = (el) => { 29 | this.containerEl = el; 30 | } 31 | 32 | handleToggle = () => { 33 | this.setState(({isWindow}) => ({ 34 | isWindow: !isWindow, 35 | isRendered: false 36 | })); 37 | } 38 | 39 | handleRender = () => { 40 | this.setState({ 41 | isRendered: true 42 | }); 43 | } 44 | 45 | handleOffsetChange = (event) => { 46 | try { 47 | const offset = JSON.parse(event.target.value); 48 | this.setState({offset}); 49 | } catch (e) { 50 | } 51 | } 52 | 53 | handleJumpToBottom = () => { 54 | const {isWindow} = this.state; 55 | if (isWindow) { 56 | window.scrollTo(0, 99999); 57 | } else { 58 | if (this.containerEl) { 59 | this.containerEl.scrollTo(0, 99999); 60 | } 61 | } 62 | } 63 | 64 | render() { 65 | const {isWindow, isRendered, offset} = this.state; 66 | 67 | return ( 68 |
69 |

react-lazily-render

70 | 71 | 75 |
76 | 80 |
81 |
82 | 85 |
86 |
87 | 88 | 89 |

Scroll the {isWindow ? 'window' : 'container'} {isRendered ? '✅' : '⬇'}

90 | 91 | {isWindow 92 | ? ( 93 | 94 | ) : ( 95 |
96 | 97 |
98 | ) 99 | } 100 | 101 |
102 | ); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('app') 8 | ); 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./src/_.test.js'], 3 | testPathIgnorePatterns: [ 4 | '/node_modules/', //ignore the node_modules 5 | '/src/_\\.test\\.js$', //ignore the test setup file 6 | '/dist', //ignore the dist directory 7 | ], 8 | verbose: true, 9 | testURL: "http://localhost/", 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lazily-render", 3 | "version": "1.2.0", 4 | "description": "Lazily render react components", 5 | "keywords": [ 6 | "react", 7 | "lazy-load", 8 | "react-lazy-load", 9 | "lazy", 10 | "load", 11 | "delay", 12 | "expensive", 13 | "performance" 14 | ], 15 | "main": "dist/es5/index.js", 16 | "jsnext:main": "dist/esnext/index.js", 17 | "module": "dist/esnext/index.js", 18 | "types": "./dist/index.d.ts", 19 | "files": [ 20 | "dist/index.d.ts", 21 | "dist/es5", 22 | "dist/esnext" 23 | ], 24 | "repository": "jameslnewell/react-lazily-render", 25 | "dependencies": { 26 | "scrollparent": "^2.0.1" 27 | }, 28 | "peerDependencies": { 29 | "react": "^16.1.1" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.4.4", 33 | "@babel/core": "^7.4.5", 34 | "babel-jest": "^24.8.0", 35 | "babel-loader": "^8.0.6", 36 | "@babel/preset-env": "^7.4.5", 37 | "@babel/preset-flow": "^7.0.0", 38 | "@babel/preset-react": "^7.0.0", 39 | "@babel/plugin-proposal-class-properties": "^7.4.4", 40 | "enzyme": "^3.10.0", 41 | "enzyme-adapter-react-16": "^1.14.0", 42 | "flow-bin": "^0.59.0", 43 | "gh-pages": "^1.1.0", 44 | "html-webpack-plugin": "^3.2.0", 45 | "jest": "^24.8.0", 46 | "jest-enzyme": "^7.0.2", 47 | "react": "^16.8.6", 48 | "react-dom": "^16.8.6", 49 | "react-test-renderer": "^16.8.6", 50 | "webpack": "^4.33.0", 51 | "webpack-cli": "^3.3.4", 52 | "webpack-dev-server": "^3.7.1" 53 | }, 54 | "scripts": { 55 | "clean": "rm -rf ./dist", 56 | "flow": "flow", 57 | "build:es5": "BABEL_ENV=es5 babel ./src -d ./dist/es5 --ignore .test.js", 58 | "build:esnext": "BABEL_ENV=esnext babel ./src -d ./dist/esnext --ignore .test.js", 59 | "build:types": "cp ./src/index.d.ts ./dist/index.d.ts", 60 | "build:example": "BABEL_ENV=es5 webpack", 61 | "build": "yarn run build:es5 && yarn run build:esnext && yarn run build:types && yarn run build:example", 62 | "deploy": "gh-pages -d ./dist/example", 63 | "dev": "BABEL_ENV=es5 webpack-dev-server", 64 | "test": "jest", 65 | "test:watch": "jest --watch", 66 | "ci": "yarn run clean && yarn run test && yarn run build", 67 | "prepublishOnly": "yarn run ci", 68 | "postpublish": "yarn run deploy" 69 | }, 70 | "license": "MIT" 71 | } 72 | -------------------------------------------------------------------------------- /src/_.test.js: -------------------------------------------------------------------------------- 1 | 2 | import Enzyme from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | Enzyme.configure({adapter: new Adapter()}); 6 | 7 | // using this hack instead of `import 'raf/polyfill';` because the polyfill keeps a global queue 8 | // which breaks across tests using Jest fake timers 9 | global.requestAnimationFrame = callback => setTimeout(callback, 16); 10 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-lazily-render' { 2 | 3 | import * as React from 'react'; 4 | 5 | export interface LazilyRenderProps { 6 | className?: string; 7 | component?: string | React.ComponentClass; 8 | offset?: number | {top?: number, right?: number, bottom?: number, left?: number}; 9 | placeholder?: React.ReactNode; 10 | content?: React.ReactNode; 11 | children?: (render: boolean) => React.ReactNode; 12 | onRender?: () => void; 13 | scrollContainer?: HTMLElement; 14 | } 15 | 16 | export default class LazilyRender extends React.Component { 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import scrollParent from 'scrollparent'; 4 | import {type Bounds, type Window} from './types'; 5 | import getViewportBounds from './utils/getViewportBounds'; 6 | import getElementBounds from './utils/getElementBounds'; 7 | import convertOffsetToBounds from './utils/convertOffsetToBounds'; 8 | import isElementInViewport from './utils/isElementInViewport' 9 | import isBackCompatMode from './utils/isBackCompatMode' 10 | import eventListenerOptions from './utils/eventListenerOptions'; 11 | 12 | export type LazilyRenderProps = { 13 | className?: string; 14 | component?: string | React.ComponentClass; 15 | offset?: number | {top?: number, right?: number, bottom?: number, left?: number}; 16 | placeholder?: React.Node; 17 | content?: React.Node; 18 | children?: (render: boolean) => React.Node; 19 | onRender?: () => void; 20 | scrollContainer?: HTMLElement; 21 | }; 22 | 23 | export type LazilyRenderState = { 24 | hasBeenScrolledIntoView: boolean; 25 | }; 26 | 27 | export default class LazilyRender extends React.Component { 28 | 29 | raf: ?number; 30 | element: ?HTMLElement; 31 | container: ?HTMLElement | ?Window; 32 | 33 | state = { 34 | hasBeenScrolledIntoView: false 35 | }; 36 | 37 | getContainer(scrollContainer: ?HTMLElement): ?HTMLElement | ?Window { 38 | if (scrollContainer) { 39 | return scrollContainer; 40 | } else { 41 | if (this.element) { 42 | const container = scrollParent(this.element); 43 | if (container === document.scrollingElement || container === document.documentElement || (!isBackCompatMode() && container == document.body)) { 44 | return window; 45 | } else { 46 | return container; 47 | } 48 | } else { 49 | return undefined; 50 | } 51 | } 52 | } 53 | 54 | getViewportBounds(): ?Bounds { 55 | return getViewportBounds(this.container) 56 | } 57 | 58 | getElementBounds(): ?Bounds { 59 | return getElementBounds(this.element); 60 | } 61 | 62 | getOffsetBounds(): ?Bounds { 63 | const {offset} = this.props; 64 | return convertOffsetToBounds(offset); 65 | } 66 | 67 | componentDidUpdate(prevProps: ?LazilyRenderProps, prevState: ?LazilyRenderState) { 68 | const { scrollContainer: prevContainer } = prevProps; 69 | const { scrollContainer: nextContainer } = this.props; 70 | // If a scroll container was defined before, do some cleanup 71 | // and bootstrap the next scroll container. 72 | if (prevContainer !== nextContainer) { 73 | // If the previous container was already utilised, no cleanup 74 | // is required - already done in LazilyRender.update(). 75 | if (!prevState.hasBeenScrolledIntoView) { 76 | this.stopListening(prevContainer); 77 | } 78 | // Set a new listener if the scrollContainer is defined, and update 79 | // the container property accordingly. Note: this should only be done 80 | // when the next container is different. 81 | this.container = this.getContainer(nextContainer); 82 | this.startListening(this.container); 83 | // Signal that the element has not been scrolled into view and 84 | // recompute its position. This will essentially 'reset' the node's 85 | // current status back to a placeholder item if need be. 86 | this.setState({ hasBeenScrolledIntoView: false }, () => { 87 | this.update(); 88 | }); 89 | } 90 | } 91 | 92 | startListening(container: ?HTMLElement) { 93 | if (container) container.addEventListener('scroll', this.update, eventListenerOptions); 94 | window.addEventListener('resize', this.update); 95 | } 96 | 97 | stopListening(container: ?HTMLElement) { 98 | if (container) container.removeEventListener('scroll', this.update, eventListenerOptions); 99 | window.removeEventListener('resize', this.update); 100 | } 101 | 102 | update = () => { 103 | cancelAnimationFrame(this.raf); 104 | this.raf = requestAnimationFrame(() => { 105 | 106 | const elementBounds = this.getElementBounds(); 107 | const viewportBounds = this.getViewportBounds(); 108 | const offsetBounds = this.getOffsetBounds(); 109 | 110 | if (!elementBounds || !viewportBounds) { 111 | return; 112 | } 113 | 114 | if (isElementInViewport(elementBounds, viewportBounds, offsetBounds)) { 115 | this.stopListening(this.container); 116 | this.setState( 117 | { 118 | hasBeenScrolledIntoView: true 119 | }, 120 | () => { 121 | const {onRender} = this.props; 122 | if (onRender) { 123 | onRender(); 124 | } 125 | } 126 | ); 127 | } 128 | 129 | }); 130 | } 131 | 132 | handleMount = (element: ?HTMLElement) => { 133 | const { scrollContainer } = this.props; 134 | this.element = element; 135 | this.container = this.getContainer(scrollContainer); 136 | } 137 | 138 | componentDidMount() { 139 | this.update(); 140 | this.startListening(this.container); 141 | } 142 | 143 | componentWillUnmount() { 144 | this.stopListening(this.container); 145 | } 146 | 147 | renderChildren() { 148 | const {placeholder, content, children} = this.props; 149 | const {hasBeenScrolledIntoView} = this.state; 150 | 151 | if (!hasBeenScrolledIntoView && placeholder) { 152 | return placeholder; 153 | } 154 | 155 | if (hasBeenScrolledIntoView && content) { 156 | return content; 157 | } 158 | 159 | if (children) { 160 | return children(hasBeenScrolledIntoView); 161 | } 162 | 163 | return null; 164 | } 165 | 166 | render() { 167 | const {className, component} = this.props; 168 | return React.createElement(component || 'div', { 169 | ref: this.handleMount, 170 | className, 171 | children: this.renderChildren() 172 | }); 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {mount,shallow} from 'enzyme'; 3 | import LazyRender from '.'; 4 | import {__setViewportBounds} from './utils/getViewportBounds'; 5 | import {__setElementBounds} from './utils/getElementBounds'; 6 | 7 | let mockScrollParent = window; 8 | jest.mock('scrollparent', () => (element) => { return mockScrollParent; }); 9 | 10 | jest.mock('./utils/eventListenerOptions', () => { 11 | return { 12 | passive: true 13 | }; 14 | }); 15 | 16 | jest.mock('./utils/getViewportBounds', () => { 17 | let bounds = undefined; 18 | const getViewportBounds = () => bounds; 19 | getViewportBounds.__setViewportBounds = val => bounds = val; 20 | return getViewportBounds; 21 | }); 22 | 23 | jest.mock('./utils/getElementBounds', () => { 24 | let bounds = undefined; 25 | const getElementBounds = () => bounds; 26 | getElementBounds.__setElementBounds = val => bounds = val; 27 | return getElementBounds; 28 | }); 29 | 30 | function setupElementInView() { 31 | __setViewportBounds({ 32 | top: 0, 33 | right: 1000, 34 | bottom: 1000, 35 | left: 0 36 | }); 37 | __setElementBounds({ 38 | top: 100, 39 | right: 200, 40 | bottom: 200, 41 | left: 100 42 | }); 43 | } 44 | 45 | function setupElementOutOfView() { 46 | __setViewportBounds({ 47 | top: 0, 48 | right: 1000, 49 | bottom: 1000, 50 | left: 0 51 | }); 52 | __setElementBounds({ 53 | top: 1100, 54 | right: 1200, 55 | bottom: 1200, 56 | left: 1100 57 | }); 58 | } 59 | 60 | let compatMode; 61 | function mockCompatMode(mode) { 62 | compatMode = Object.getOwnPropertyDescriptor(Document.prototype, 'compatMode'); 63 | Object.defineProperty(document, 'compatMode', { value: mode, writable: false, configurable: true }); 64 | } 65 | function unmockCompatMode() { 66 | Object.defineProperty(document, 'compatMode', compatMode); 67 | } 68 | 69 | function wrapper(node) { 70 | jest.useFakeTimers(); 71 | const wrapper = mount(node); 72 | jest.runAllTimers(); 73 | wrapper.update(); 74 | return wrapper; 75 | } 76 | 77 | // return the div wrapper 78 | function getWrapper(element) { 79 | return element.children().first(); 80 | } 81 | 82 | describe('LazyRender', () => { 83 | const placeholder = '...'; 84 | const content = 'Hello World!' 85 | const children = 'foo bar'; 86 | 87 | describe('.render()', () => { 88 | 89 | it('should render the class name on the wrapper', () => { 90 | const wrapper = mount( 91 | 92 | {() => HelloWorld!} 93 | 94 | ); 95 | expect(wrapper.prop('className')).toEqual('my-cool-class'); 96 | }); 97 | 98 | describe(`when component="span"`, () => { 99 | it(`should render the wrapper as `, () => { 100 | const wrapper = shallow( 101 | 102 | {() => HelloWorld!} 103 | 104 | ); 105 | expect(wrapper.type()).toEqual('span'); 106 | }); 107 | }); 108 | 109 | describe(`when component="div"`, () => { 110 | it(`should render the wrapper as
`, () => { 111 | const wrapper = shallow( 112 | 113 | {() => HelloWorld!} 114 | 115 | ); 116 | expect(wrapper.type()).toEqual('div'); 117 | }); 118 | }); 119 | 120 | it('should call the render fn with true when the component is visible in the viewport', () => { 121 | setupElementInView(); 122 | const render = jest.fn(); 123 | const element = wrapper( 124 | 125 | {render} 126 | 127 | ); 128 | expect(render).toBeCalledWith(true); 129 | }); 130 | 131 | it('should call the render fn with false when the component is not visible in the viewport', () => { 132 | setupElementOutOfView(); 133 | const render = jest.fn(); 134 | const element = wrapper( 135 | 136 | {render} 137 | 138 | ); 139 | expect(render).toBeCalledWith(false); 140 | }); 141 | 142 | describe('has not been scrolled into view:', () => { 143 | beforeEach(() => setupElementOutOfView()); 144 | 145 | it('should render the "placeholder" when there is a "placeholder"', () => { 146 | const element = wrapper( 147 | 148 | {() => children} 149 | 150 | ); 151 | expect(getWrapper(element).contains(placeholder)).toBeTruthy(); 152 | }); 153 | 154 | it('should render the "render fn" when there is no "placeholder"', () => { 155 | const element = wrapper( 156 | 157 | {() => children} 158 | 159 | ); 160 | expect(getWrapper(element).contains(children)).toBeTruthy(); 161 | }); 162 | 163 | it('should render "null" when there is no "placeholder" and no "render fn"', () => { 164 | const element = wrapper(); 165 | expect(getWrapper(element).children().exists()).toBeFalsy(); 166 | }); 167 | 168 | }); 169 | 170 | describe('has been scrolled into view:', () => { 171 | beforeEach(() => setupElementInView()); 172 | 173 | it('should render the "content" when there is "content"', () => { 174 | const element = wrapper( 175 | 176 | {() => children} 177 | 178 | ); 179 | expect(getWrapper(element).contains(content)).toBeTruthy(); 180 | }); 181 | 182 | it('should render the "render fn" when there is no "content"', () => { 183 | const element = wrapper( 184 | 185 | {() => children} 186 | 187 | ); 188 | expect(getWrapper(element).contains(children)).toBeTruthy(); 189 | }); 190 | 191 | it('should render "null" when there is no "content" and no "render fn"', () => { 192 | const element = wrapper(); 193 | expect(getWrapper(element).children().exists()).toBeFalsy(); 194 | }); 195 | 196 | }); 197 | 198 | }); 199 | 200 | describe('Event handling', () => { 201 | 202 | let windowAddEventSpy; 203 | let bodyAddEventSpy; 204 | let windowRemoveEventSpy; 205 | let bodyRemoveEventSpy; 206 | 207 | beforeEach(() => { 208 | windowAddEventSpy = jest.spyOn(window, 'addEventListener'); 209 | bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener'); 210 | windowRemoveEventSpy = jest.spyOn(window, 'removeEventListener'); 211 | bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener'); 212 | }); 213 | 214 | it('should use passive event listeners when available', () => { 215 | const element = wrapper( 216 | 217 | {() => children} 218 | 219 | ); 220 | 221 | expect(windowAddEventSpy).toBeCalledWith('scroll', expect.anything(), { 222 | passive: true 223 | }); 224 | 225 | element.unmount(); 226 | 227 | expect(windowRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), { 228 | passive: true 229 | }); 230 | }); 231 | 232 | it('should use passive event listeners when available, container specified', () => { 233 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener'); 234 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener'); 235 | 236 | const element = wrapper( 237 | 238 | {() => children} 239 | 240 | ); 241 | 242 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 243 | 244 | bodyAddEventSpy.mockClear(); 245 | bodyRemoveEventSpy.mockClear(); 246 | 247 | element.unmount(); 248 | 249 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 250 | }); 251 | 252 | it('should cleanup and setup containers accurately when container changes', () => { 253 | // Aim: consumer wants to switch to a nested scroll container. 254 | const innerScrollContainer = document.createElement('div', { className: 'inner-scroll-container' }); 255 | document.body.appendChild(innerScrollContainer); 256 | 257 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener'); 258 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener'); 259 | const containerAddEventSpy = jest.spyOn(innerScrollContainer, 'addEventListener'); 260 | const containerRemoveEventSpy = jest.spyOn(innerScrollContainer, 'removeEventListener'); 261 | 262 | const element = wrapper( 263 | 264 | {() => children} 265 | 266 | ); 267 | 268 | // Initial events should be attached to the first scroll container specified. 269 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 270 | bodyAddEventSpy.mockClear(); 271 | 272 | // Switch to the next container. 273 | element.setProps({ scrollContainer: innerScrollContainer }); 274 | expect(element.prop('scrollContainer')).toBe(innerScrollContainer); 275 | 276 | // Next, a cleanup should be done of the first container, and setup of the new container. 277 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 278 | expect(containerAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 279 | containerAddEventSpy.mockClear(); 280 | bodyRemoveEventSpy.mockClear(); 281 | 282 | // Lastly, the component teardown should reference the second container. 283 | element.unmount(); 284 | expect(containerRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 285 | containerRemoveEventSpy.mockClear(); 286 | }); 287 | 288 | it('should listen to the window when scrollparent returns body in CSS1Compat mode', () => { 289 | mockCompatMode('CSS1Compat'); 290 | mockScrollParent = document.body; 291 | 292 | const element = wrapper( 293 | 294 | {() => children} 295 | 296 | ); 297 | 298 | expect(windowAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 299 | expect(bodyAddEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything()); 300 | 301 | windowAddEventSpy.mockClear(); 302 | bodyAddEventSpy.mockClear(); 303 | windowRemoveEventSpy.mockClear(); 304 | bodyRemoveEventSpy.mockClear(); 305 | 306 | element.unmount(); 307 | 308 | expect(windowRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 309 | expect(bodyRemoveEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything()); 310 | 311 | unmockCompatMode(); 312 | }); 313 | 314 | it('should listen to the body when scrollparent returns body in BackCompat mode', () => { 315 | mockCompatMode('BackCompat'); 316 | mockScrollParent = document.body; 317 | const windowAddEventSpy = jest.spyOn(window, 'addEventListener'); 318 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener'); 319 | const windowRemoveEventSpy = jest.spyOn(window, 'removeEventListener'); 320 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener'); 321 | 322 | const element = wrapper( 323 | 324 | {() => children} 325 | 326 | ); 327 | 328 | expect(windowAddEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything()); 329 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 330 | 331 | windowAddEventSpy.mockClear(); 332 | bodyAddEventSpy.mockClear(); 333 | windowRemoveEventSpy.mockClear(); 334 | bodyRemoveEventSpy.mockClear(); 335 | 336 | element.unmount(); 337 | 338 | expect(windowRemoveEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything()); 339 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything()); 340 | 341 | unmockCompatMode(); 342 | }); 343 | 344 | }); 345 | 346 | }); 347 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | 2 | type Window = EventTarget; 3 | 4 | export type Bounds = { 5 | left: number; 6 | top: number; 7 | right: number; 8 | bottom: number; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/convertOffsetToBounds.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | import {type Bounds} from '../types'; 3 | 4 | export default function(offset?: number | {top?: number, right?: number, bottom?: number, left?: number}): ?Bounds { 5 | 6 | if (!offset) { 7 | return undefined; 8 | } 9 | 10 | let offsetTop; 11 | let offsetRight; 12 | let offsetBottom; 13 | let offsetLeft; 14 | 15 | if (typeof offset === 'object') { 16 | offsetTop = offset.top || 0; 17 | offsetRight = offset.right || 0; 18 | offsetBottom = offset.bottom || 0; 19 | offsetLeft = offset.left || 0; 20 | } else { 21 | offsetTop = offset || 0; 22 | offsetRight = offset || 0; 23 | offsetBottom = offset || 0; 24 | offsetLeft = offset || 0; 25 | } 26 | 27 | return { 28 | top: offsetTop, 29 | right: offsetRight, 30 | bottom: offsetBottom, 31 | left: offsetLeft 32 | }; 33 | 34 | } -------------------------------------------------------------------------------- /src/utils/eventListenerOptions.js: -------------------------------------------------------------------------------- 1 | const isPassiveListenerSupported = () => { 2 | let supported = false; 3 | 4 | try { 5 | const opts = Object.defineProperty({}, 'passive', { 6 | get() { 7 | supported = true; 8 | } 9 | }); 10 | 11 | window.addEventListener('test', null, opts); 12 | window.removeEventListener('test', null, opts); 13 | } catch (e) {} 14 | 15 | return supported; 16 | }; 17 | 18 | export default isPassiveListenerSupported() ? { 19 | passive: true 20 | } : undefined; -------------------------------------------------------------------------------- /src/utils/getElementBounds.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | import {type Bounds} from '../types'; 3 | 4 | export default function getElementBounds(element: ?HTMLElement): ?Bounds { 5 | if (!element) { 6 | return undefined; 7 | } 8 | const rect = element.getBoundingClientRect(); 9 | return { 10 | left: window.pageXOffset + rect.left, 11 | right: window.pageXOffset + rect.left + rect.width, 12 | top: window.pageYOffset + rect.top, 13 | bottom: window.pageYOffset + rect.top + rect.height 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/getElementBounds.test.js: -------------------------------------------------------------------------------- 1 | 2 | describe.skip('getElementBounds()', () => { 3 | it(); 4 | }); 5 | -------------------------------------------------------------------------------- /src/utils/getViewportBounds.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | import {type Bounds, type Window} from '../types'; 3 | import getElementBounds from './getElementBounds'; 4 | 5 | export default function(container: ?HTMLElement | ?Window): ?Bounds { 6 | if (!container) { 7 | return undefined; 8 | } 9 | if (container === window) { 10 | return { 11 | top: window.pageYOffset, 12 | left: window.pageXOffset, 13 | bottom: window.pageYOffset + window.innerHeight, 14 | right: window.pageXOffset + window.innerWidth 15 | }; 16 | } else { 17 | const bounds = getElementBounds(container); 18 | if (bounds) { 19 | return { 20 | ...bounds, 21 | bottom: bounds.top + container.offsetHeight, 22 | right: bounds.left + container.offsetWidth 23 | }; 24 | } else { 25 | return undefined; 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/utils/getViewportBounds.test.js: -------------------------------------------------------------------------------- 1 | 2 | describe.skip('getViewportBounds()', () => { 3 | it(); 4 | }); 5 | -------------------------------------------------------------------------------- /src/utils/isBackCompatMode.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return document.compatMode === "BackCompat"; 3 | } -------------------------------------------------------------------------------- /src/utils/isElementInViewport.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | import {type Bounds} from '../types'; 3 | 4 | export default function (elementBounds: Bounds, viewportBounds: Bounds, offset?: Bounds): boolean { 5 | const offsetTop = offset && offset.top || 0; 6 | const offsetRight = offset && offset.right || 0; 7 | const offsetBottom = offset && offset.bottom || 0; 8 | const offsetLeft = offset && offset.left || 0; 9 | return ( 10 | elementBounds.bottom + offsetBottom >= viewportBounds.top && elementBounds.top - offsetTop <= viewportBounds.bottom && 11 | elementBounds.right + offsetRight >= viewportBounds.left && elementBounds.left - offsetLeft <= viewportBounds.right 12 | ); 13 | 14 | } -------------------------------------------------------------------------------- /src/utils/isElementInViewport.test.js: -------------------------------------------------------------------------------- 1 | import isElementInViewport from './isElementInViewport'; 2 | 3 | describe('isElementInViewport()', () => { 4 | 5 | it('should return true when the element is fully in the viewport', () => { 6 | const result = isElementInViewport( 7 | { 8 | left: 0, 9 | top: 100, 10 | right: 100, 11 | bottom: 300 12 | }, 13 | { 14 | left: 0, 15 | top: 0, 16 | right: 300, 17 | bottom: 300 18 | } 19 | ); 20 | expect(result).toBeTruthy(); 21 | }); 22 | 23 | it('should return true when the bottom half of the element is in the viewport', () => { 24 | const result = isElementInViewport( 25 | { 26 | left: 0, 27 | top: 100, 28 | right: 100, 29 | bottom: 200 30 | }, 31 | { 32 | left: 0, 33 | top: 150, 34 | right: 300, 35 | bottom: 350 36 | } 37 | ); 38 | expect(result).toBeTruthy(); 39 | }); 40 | 41 | it('should return true when the top half of the element is in the viewport', () => { 42 | const result = isElementInViewport( 43 | { 44 | left: 0, 45 | top: 100, 46 | right: 100, 47 | bottom: 300 48 | }, 49 | { 50 | left: 0, 51 | top: 150, 52 | right: 300, 53 | bottom: 200 54 | } 55 | ); 56 | expect(result).toBeTruthy(); 57 | }); 58 | 59 | it('should return false when the element is fully above the viewport', () => { 60 | const result = isElementInViewport( 61 | { 62 | left: 0, 63 | top: 100, 64 | right: 300, 65 | bottom: 200 66 | }, 67 | { 68 | left: 0, 69 | top: 300, 70 | right: 1000, 71 | bottom: 1100 72 | } 73 | ); 74 | expect(result).toBeFalsy(); 75 | }); 76 | 77 | it('should return false when the element is fully below the viewport', () => { 78 | const result = isElementInViewport( 79 | { 80 | left: 0, 81 | top: 900, 82 | right: 300, 83 | bottom: 1000 84 | }, 85 | { 86 | left: 0, 87 | top: 0, 88 | right: 1000, 89 | bottom: 800 90 | } 91 | ); 92 | expect(result).toBeFalsy(); 93 | }); 94 | 95 | describe('With offset:', () => { 96 | 97 | it('should return true when the element is fully in the viewport', () => { 98 | const result = isElementInViewport( 99 | { 100 | top: 1400, 101 | right: 1800, 102 | bottom: 1800, 103 | left: 1400 104 | }, 105 | { 106 | top: 1000, 107 | right: 2000, 108 | bottom: 2000, 109 | left: 1000 110 | }, 111 | { 112 | left: 50, 113 | top: 50, 114 | right: 50, 115 | bottom: 50 116 | } 117 | ); 118 | expect(result).toBeTruthy(); 119 | }); 120 | 121 | describe('When element near the top of the viewport:', () => { 122 | 123 | it('should return true when the element is partially in the viewport', () => { 124 | const result = isElementInViewport( 125 | { 126 | top: 800, 127 | right: 2000, 128 | bottom: 1200, 129 | left: 1000 130 | }, 131 | { 132 | top: 1000, 133 | right: 2000, 134 | bottom: 2000, 135 | left: 1000 136 | }, 137 | { 138 | left: 0, 139 | top: 0, 140 | right: 0, 141 | bottom: 50 142 | } 143 | ); 144 | expect(result).toBeTruthy(); 145 | }); 146 | 147 | it('should return true when the offset is partially in the viewport', () => { 148 | const result = isElementInViewport( 149 | { 150 | top: 575, 151 | right: 2000, 152 | bottom: 975, 153 | left: 1000 154 | }, 155 | { 156 | top: 1000, 157 | right: 2000, 158 | bottom: 2000, 159 | left: 1000 160 | }, 161 | { 162 | left: 0, 163 | top: 0, 164 | right: 0, 165 | bottom: 50 166 | } 167 | ); 168 | expect(result).toBeTruthy(); 169 | }); 170 | 171 | it('should return false when the element and the offset is not in the viewport', () => { 172 | const result = isElementInViewport( 173 | { 174 | top: 500, 175 | right: 2000, 176 | bottom: 900, 177 | left: 1000 178 | }, 179 | { 180 | top: 1000, 181 | right: 2000, 182 | bottom: 2000, 183 | left: 1000 184 | }, 185 | { 186 | left: 0, 187 | top: 0, 188 | right: 0, 189 | bottom: 50 190 | } 191 | ); 192 | expect(result).toBeFalsy(); 193 | }); 194 | 195 | }); 196 | 197 | describe('When element is near the bottom of the viewport:', () => { 198 | 199 | it('should return true when the element is partially in the viewport', () => { 200 | const result = isElementInViewport( 201 | { 202 | top: 1800, 203 | right: 2000, 204 | bottom: 2200, 205 | left: 1000 206 | }, 207 | { 208 | top: 1000, 209 | right: 2000, 210 | bottom: 2000, 211 | left: 1000 212 | }, 213 | { 214 | left: 0, 215 | top: 50, 216 | right: 0, 217 | bottom: 0 218 | } 219 | ); 220 | expect(result).toBeTruthy(); 221 | }); 222 | 223 | it('should return true when the offset is partially in the viewport', () => { 224 | const result = isElementInViewport( 225 | { 226 | top: 2025, 227 | right: 2000, 228 | bottom: 2425, 229 | left: 1000 230 | }, 231 | { 232 | top: 1000, 233 | right: 2000, 234 | bottom: 2000, 235 | left: 1000 236 | }, 237 | { 238 | left: 0, 239 | top: 50, 240 | right: 0, 241 | bottom: 0 242 | } 243 | ); 244 | expect(result).toBeTruthy(); 245 | }); 246 | 247 | it('should return false when the element and the offset is not in the viewport', () => { 248 | const result = isElementInViewport( 249 | { 250 | top: 2100, 251 | right: 2000, 252 | bottom: 2500, 253 | left: 1000 254 | }, 255 | { 256 | top: 1000, 257 | right: 2000, 258 | bottom: 2000, 259 | left: 1000 260 | }, 261 | { 262 | left: 0, 263 | top: 50, 264 | right: 0, 265 | bottom: 0 266 | } 267 | ); 268 | expect(result).toBeFalsy(); 269 | }); 270 | 271 | }); 272 | 273 | describe('When element is near the left of the viewport:', () => { 274 | 275 | it('should return true when the element is partially in the viewport', () => { 276 | const result = isElementInViewport( 277 | { 278 | top: 1000, 279 | right: 1200, 280 | bottom: 2000, 281 | left: 800 282 | }, 283 | { 284 | top: 1000, 285 | right: 2000, 286 | bottom: 2000, 287 | left: 1000 288 | }, 289 | { 290 | left: 0, 291 | top: 0, 292 | right: 50, 293 | bottom: 0 294 | } 295 | ); 296 | expect(result).toBeTruthy(); 297 | }); 298 | 299 | it('should return true when the offset is partially in the viewport', () => { 300 | const result = isElementInViewport( 301 | { 302 | top: 1000, 303 | right: 975, 304 | bottom: 2000, 305 | left: 575 306 | }, 307 | { 308 | top: 1000, 309 | right: 2000, 310 | bottom: 2000, 311 | left: 1000 312 | }, 313 | { 314 | left: 0, 315 | top: 0, 316 | right: 50, 317 | bottom: 0 318 | } 319 | ); 320 | expect(result).toBeTruthy(); 321 | }); 322 | 323 | it('should return false when the element and the offset is not in the viewport', () => { 324 | const result = isElementInViewport( 325 | { 326 | top: 1000, 327 | right: 900, 328 | bottom: 2000, 329 | left: 500 330 | }, 331 | { 332 | top: 1000, 333 | right: 2000, 334 | bottom: 2000, 335 | left: 1000 336 | }, 337 | { 338 | left: 0, 339 | top: 0, 340 | right: 50, 341 | bottom: 0 342 | } 343 | ); 344 | expect(result).toBeFalsy(); 345 | }); 346 | 347 | }); 348 | 349 | describe('When element is near the right of the viewport:', () => { 350 | 351 | it('should return true when the element is partially in the viewport', () => { 352 | const result = isElementInViewport( 353 | { 354 | top: 1000, 355 | right: 2200, 356 | bottom: 2000, 357 | left: 1800 358 | }, 359 | { 360 | top: 1000, 361 | right: 2000, 362 | bottom: 2000, 363 | left: 1000 364 | }, 365 | { 366 | left: 50, 367 | top: 0, 368 | right: 0, 369 | bottom: 0 370 | } 371 | ); 372 | expect(result).toBeTruthy(); 373 | }); 374 | 375 | it('should return true when the offset is partially in the viewport', () => { 376 | const result = isElementInViewport( 377 | { 378 | top: 1000, 379 | right: 2425, 380 | bottom: 2000, 381 | left: 2025 382 | }, 383 | { 384 | top: 1000, 385 | right: 2000, 386 | bottom: 2000, 387 | left: 1000 388 | }, 389 | { 390 | left: 50, 391 | top: 0, 392 | right: 0, 393 | bottom: 0 394 | } 395 | ); 396 | expect(result).toBeTruthy(); 397 | }); 398 | 399 | it('should return false when the element and the offset is not in the viewport', () => { 400 | const result = isElementInViewport( 401 | { 402 | top: 1000, 403 | right: 2500, 404 | bottom: 2000, 405 | left: 2100 406 | }, 407 | { 408 | top: 1000, 409 | right: 2000, 410 | bottom: 2000, 411 | left: 1000 412 | }, 413 | { 414 | left: 50, 415 | top: 0, 416 | right: 0, 417 | bottom: 0 418 | } 419 | ); 420 | expect(result).toBeFalsy(); 421 | }); 422 | 423 | }); 424 | 425 | }); 426 | 427 | }); 428 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './example/index.js', 6 | output: { 7 | path: path.resolve('./dist/example'), 8 | filename: './index.js' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /(node_modules)/, 15 | use: { 16 | loader: 'babel-loader' 17 | } 18 | } 19 | ] 20 | }, 21 | resolve: { 22 | alias: { 23 | 'react-lazily-render': path.resolve('./src') 24 | } 25 | }, 26 | plugins: [ 27 | new HtmlWebpackPlugin({ 28 | title: 'react-lazily-render', 29 | filename: './index.html', 30 | template: './src/index.html', 31 | }) 32 | ] 33 | }; 34 | --------------------------------------------------------------------------------