├── .editorconfig ├── .gitignore ├── .npmignore ├── .parcelrc ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── bundler └── parcel-transformer-replace │ ├── package.json │ └── transformer.js ├── docs ├── api │ ├── ObserveViewport_connectViewport_useViewport.md │ ├── ViewportProvider.md │ ├── types.md │ └── useRect.md └── concepts │ ├── defer_events.md │ ├── recalculateLayoutBeforeUpdate.md │ └── scheduler.md ├── examples ├── index.html ├── index.tsx └── styles.css ├── jest.config.js ├── lib ├── ConnectViewport.tsx ├── ObserveViewport.tsx ├── ViewportCollector.tsx ├── ViewportProvider.tsx ├── __tests__ │ ├── ObserveViewport.client.test.tsx │ ├── hooks.client.test.tsx │ ├── server.test.tsx │ └── utils.test.ts ├── hooks.ts ├── index.ts ├── modules.d.ts ├── types.ts └── utils.ts ├── package.json ├── tsconfig.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | examples-dist/ 3 | dist/ 4 | .parcel-cache/ 5 | .rpt2_cache/ 6 | .vscode/ 7 | coverage/ 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | examples/ 3 | node_modules/ 4 | examples-dist/ 5 | .vscode/ 6 | .cache/ 7 | .parcel-cache/ 8 | coverage/ 9 | .rpt2_cache/ 10 | .editorconfig 11 | .prettierrc 12 | rollup.config.js 13 | tsconfig.json 14 | tsconfig.test.json 15 | tslint.json 16 | .travis.yml 17 | jest.config.js 18 | dev-tools 19 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}": ["parcel-transformer-replace", "..."] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | matrix: 3 | include: 4 | - node_js: "13" 5 | - node_js: "12" 6 | - node_js: "10" 7 | - node_js: "8" 8 | 9 | script: "yarn test -- --coverage" 10 | after_success: cat ./coverage/lcov.info | npx coveralls 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jannick Garthen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Viewport Utils 2 | 3 | A set of low level utility components for react to make working with the viewport (e.g scroll position or size of the page) easy to use and performant by default. 4 | 5 | ![](https://img.shields.io/npm/l/react-viewport-utils.svg) 6 | [![](https://img.shields.io/npm/v/react-viewport-utils.svg)](https://www.npmjs.com/package/react-viewport-utils) 7 | ![](https://img.shields.io/david/garthenweb/react-viewport-utils.svg) 8 | [![](https://img.shields.io/bundlephobia/minzip/react-viewport-utils.svg)](https://bundlephobia.com/result?p=react-viewport-utils) 9 | [![Build Status](https://travis-ci.org/garthenweb/react-viewport-utils.svg?branch=master)](https://travis-ci.org/garthenweb/react-viewport-utils) 10 | [![Coverage Status](https://coveralls.io/repos/github/garthenweb/react-viewport-utils/badge.svg?branch=master)](https://coveralls.io/github/garthenweb/react-viewport-utils?branch=master) 11 | 12 | See the example folder for more information about what you can build with it. 13 | 14 | ## Why? 15 | 16 | On a website with more sophisticated user interactions a lot of components need access to viewport information to e.g. know whether they are in the viewport, should resize or trigger an animation. 17 | 18 | Most of the libraries reimplement the required functionality for that kind of features on its own over and over again. Those functionalities are not just hard to implement but can also, if not done well, cause the UX to suffer by introducing [layout thrashing](https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing) and therefore [jank](http://jankfree.org/) and will also cause the bundle size to grow which reduce the [time to interaction](https://philipwalton.com/articles/why-web-developers-need-to-care-about-interactivity/). Further its hard to prioritize between highly and less important events if the implementation is not bundled in one central position. 19 | 20 | This library solves all those issues by 21 | 22 | * using one central event handler per event to collect data 23 | * triggers updates to components using request animation frame 24 | * allows to prioritize the importance of updates at runtime which allows to drop frames for less important updates in case the main thread is busy 25 | * implements patterns like `onUpdate` callbacks, [render props](https://reactjs.org/docs/render-props.html), [higher order components](https://reactjs.org/docs/higher-order-components.html) and [hooks](https://reactjs.org/docs/hooks-intro.html) which make the developer experience as simple as possible and allows the developer to concentrate on the application and not on global event handling. 26 | 27 | ## Installation/ requirements 28 | 29 | Please note that `react` version 16.3 or higher is required for this library to work because it is using the [context](https://reactjs.org/docs/context.html) as well as [references](https://reactjs.org/docs/refs-and-the-dom.html) api. 30 | 31 | ``` 32 | npm install --save react-viewport-utils 33 | ``` 34 | 35 | By default the library ships with Typescript definitions, so there is no need to install a separate dependency. Typescript is no a requirement, all type definition are served within separate files. 36 | 37 | For detection of some resize events the `ResizeObserver` API is used internally which is not supported in some browsers. Please make sure to implement a polyfill on your own in case its required for your application. 38 | 39 | ## Supported Environments 40 | 41 | ### Browsers 42 | 43 | The goal is to support the most recent versions of all major browsers (Edge, Safari, Chrome and Firefox). 44 | 45 | We try to be downward compatible with older browsers when possible to at least not throw errors, but older versions will not be test at all. 46 | 47 | In case you have specific requirements, please fill an issue or create a PR so we can discuss about them. 48 | 49 | ### NodeJS 50 | 51 | The project aims to support recent releases of v8 and v10 and higher of NodeJS. 52 | 53 | ## Documentation 54 | 55 | ### API 56 | 57 | * [`ViewportProvider`](docs/api/ViewportProvider.md) 58 | * [`ObserveViewport`](docs/api/ObserveViewport_connectViewport_useViewport.md#render-props-event-handler-observeviewport) 59 | * [`connectViewport`](docs/api/ObserveViewport_connectViewport_useViewport.md#hoc-connectviewport) 60 | * [`useViewport`](docs/api/ObserveViewport_connectViewport_useViewport.md#hooks-useviewport-usescroll-usedimensions-useLayoutSnapshot) 61 | * [`useMutableViewport`](https://github.com/garthenweb/react-viewport-utils/blob/master/docs/api/ObserveViewport_connectViewport_useViewport.md#hooks-usemutableviewport) 62 | * [`useScroll`](docs/api/ObserveViewport_connectViewport_useViewport.md#hooks-useviewport-usescroll-usedimensions-useLayoutSnapshot) 63 | * [`useDimensions`](docs/api/ObserveViewport_connectViewport_useViewport.md#hooks-useviewport-usescroll-usedimensions-useLayoutSnapshot) 64 | * [`useLayoutSnapshot`](docs/api/ObserveViewport_connectViewport_useViewport.md#hooks-useviewport-usescroll-usedimensions-useLayoutSnapshot) 65 | * [`useViewportEffect`](docs/api/ObserveViewport_connectViewport_useViewport.md#hook-effects-useViewportEffect-useScrollEffect-useDimensionsEffect) 66 | * [`useScrollEffect`](docs/api/ObserveViewport_connectViewport_useViewport.md#hook-effects-useViewportEffect-useScrollEffect-useDimensionsEffect) 67 | * [`useDimensionsEffect`](docs/api/ObserveViewport_connectViewport_useViewport.md#hook-effects-useViewportEffect-useScrollEffect-useDimensionsEffect) 68 | * [`useRect`](docs/api/useRect.md#useRect) 69 | * [`useRectEffect`](docs/api/useRect.md#useRectEffect) 70 | * [Types](docs/api/types.md) 71 | 72 | ### Concepts 73 | 74 | * [Experimental Scheduler](docs/concepts/scheduler.md) 75 | * [recalculateLayoutBeforeUpdate](docs/concepts/recalculateLayoutBeforeUpdate.md) 76 | * [Defer Events](docs/concepts/defer_events.md) 77 | 78 | ## License 79 | 80 | Licensed under the [MIT License](https://opensource.org/licenses/mit-license.php). 81 | -------------------------------------------------------------------------------- /bundler/parcel-transformer-replace/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parcel-transformer-replace", 3 | "version": "1.0.0", 4 | "main": "./transformer.js", 5 | "dependencies": { 6 | "@parcel/plugin": "^2.0.0" 7 | }, 8 | "engines": { 9 | "parcel": "^2.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bundler/parcel-transformer-replace/transformer.js: -------------------------------------------------------------------------------- 1 | const { Transformer } = require('@parcel/plugin'); 2 | const pkg = require('../../package.json'); 3 | 4 | module.exports.default = new Transformer({ 5 | async transform({ asset }) { 6 | const code = await asset.getCode(); 7 | // TODO consider adding a source map. Currently, we put the variable length to the exact same size to not make the code jump, but this is a bit odd and error prone... 8 | const result = code.replace(/_VERS_/gm, pkg.version); 9 | 10 | asset.setCode(result); 11 | return [asset]; 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /docs/api/ObserveViewport_connectViewport_useViewport.md: -------------------------------------------------------------------------------- 1 | # Observe the Viewport 2 | 3 | Dependent on the use case we support different ways to connect to the viewport properties. All options described in this document will expose the current `scroll` and `dimensions` information collected by a `ViewportProvider`. 4 | 5 | ## Render Props/ Event Handler: `ObserveViewport` 6 | 7 | Render props are easy to implement in the most situations but the event handler allows more control about performance and to trigger side effects. 8 | 9 | ### API 10 | 11 | | Property | Type | Required? | Description | 12 | |:---|:---|:---:|:---| 13 | | onUpdate | function | | Triggers as soon as a viewport update was detected. Contains the `Viewport` as the first argument and the last return of `recalculateLayoutBeforeUpdate` as the second argument | 14 | | recalculateLayoutBeforeUpdate | function | | Enables a way to calculate layout information for all components as a badge before the onUpdate call. Contains the `Viewport` as the first argument. See [recalculateLayoutBeforeUpdate](../concepts/recalculateLayoutBeforeUpdate.md) | 15 | | children | function | | Like `onUpdate` but expects to return that will be rendered on the page. Contains the `Viewport` as the first argument. | 16 | | deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md) | 17 | | priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 18 | | disableScrollUpdates | boolean | | Disables updates to scroll events | 19 | | disableDimensionsUpdates | boolean | | Disables updates to dimensions events | 20 | 21 | ### Example 22 | 23 | ``` javascript 24 | import * as React from 'react'; 25 | import { 26 | ViewportProvider, 27 | ObserveViewport, 28 | } from 'react-viewport-uitls'; 29 | 30 | const handleUpdate = ({ scroll, dimensions }) { 31 | console.log(scroll, dimensions); 32 | } 33 | 34 | render( 35 | 36 |
37 | 38 |
39 | 40 | {({ scroll }) =>
{scroll.x}
} 41 |
42 |
, 43 | document.querySelector('main') 44 | ); 45 | ``` 46 | 47 | ## HOC: `connectViewport` 48 | 49 | This is just a wrapper for the `ObserveViewport` to implement the HOC pattern. 50 | 51 | ### API 52 | 53 | | Property | Type | Required? | Description | 54 | |:---|:---|:---:|:---| 55 | | omit | `['scroll', 'dimensions']` | | Allows to disable scroll or dimensions events for the higher order component | 56 | | deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md). | 57 | | options.priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 58 | 59 | ## Example 60 | 61 | ``` javascript 62 | import * as React from 'react'; 63 | import { 64 | ViewportProvider, 65 | connectViewport, 66 | } from 'react-viewport-utils'; 67 | 68 | const Component = ({ scroll, dimensions }) => ( 69 | <> 70 |
Dimension (inner)width: ${dimensions.width}
71 |
Dimension (inner)height: ${dimensions.height}
72 |
Scroll X: {scroll.x}
73 |
Scroll Y: {scroll.y}
74 | 75 | ); 76 | const ConnectedComponent = connectViewport()(Component); 77 | 78 | render( 79 | 80 |
81 | 82 |
83 |
, 84 | document.querySelector('main') 85 | ); 86 | ``` 87 | 88 | ## Hooks: `useViewport`, `useScroll`, `useDimensions`, `useLayoutSnapshot` 89 | 90 | **!!! Hooks require a `ViewportProvider` as a parent and only work with react v16.7.0 !!!** 91 | 92 | ### API 93 | 94 | | Argument | Type | Required? | Description | 95 | |:---|:---|:---:|:---| 96 | | options.disableScrollUpdates | boolean | | Disables updates to scroll events (only for `useViewport` and `useLayoutSnapshot`) | 97 | | options.disableDimensionsUpdates | boolean | | Disables updates to dimensions events (only for `useViewport` and `useLayoutSnapshot`) | 98 | | options.deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md) | 99 | | options.priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 100 | 101 | ## Hooks: `useMutableViewport` 102 | 103 | **!!! Hooks require a `ViewportProvider` as a parent and only work with react v16.7.0 !!!** 104 | 105 | Exposes the current viewport state as a mutable and readonly object. It will not trigger updates when the value on the viewport change but allows to access the current and most up to date information at any time without any negative performance impact. 106 | 107 | ### Example 108 | 109 | ``` javascript 110 | import * as React from 'react'; 111 | import { useScroll, useDimensions } from 'react-viewport-utils'; 112 | 113 | function Component() { 114 | const ref = React.useRef() 115 | const mutableViewport = useMutableViewport(); 116 | useRectEffect((rect) => { 117 | console.log( 118 | 'Is element above the current scroll position?', 119 | mutableViewport.scroll.y > rect.bottom 120 | ) 121 | }, ref) 122 | 123 | return ( 124 |
125 | ); 126 | } 127 | ``` 128 | 129 | ## Hook Effects: `useViewportEffect`, `useScrollEffect`, `useDimensionsEffect` 130 | 131 | Hook effects allow to trigger side effects on change without updating the component. 132 | 133 | **!!! Hooks require a `ViewportProvider` as a parent and only work with react v16.7.0 !!!** 134 | 135 | ### API 136 | 137 | | Argument | Type | Required? | Description | 138 | |:---|:---|:---:|:---| 139 | | effect | (Viewport \| Scroll \| Dimensions) => void | x | Disables updates to scroll events (only for `useViewport`) | 140 | | options.disableScrollUpdates | boolean | | Disables updates to scroll events (only for `useViewport`) | 141 | | options.disableDimensionsUpdates | boolean | | Disables updates to dimensions events (only for `useViewport`) | 142 | | options.deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md) | 143 | | options.priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 144 | | options.recalculateLayoutBeforeUpdate | function | | Enables a way to calculate layout information for all components as a badge before the effect call. Contains `Viewport`, `Scroll` or `Dimensions` as the first argument, dependent of the used hook. See [recalculateLayoutBeforeUpdate](../concepts/recalculateLayoutBeforeUpdate.md) | 145 | | deps | array | | Array with dependencies. In case a value inside the array changes, this will force an update to the effect function | 146 | 147 | ### Example 148 | 149 | ``` javascript 150 | import * as React from 'react'; 151 | import { useScrollEffect, useViewportEffect } from 'react-viewport-utils'; 152 | 153 | function Component() { 154 | const ref = React.useRef() 155 | useScrollEffect((scroll) => { 156 | console.log(scroll); 157 | }); 158 | useViewportEffect((viewport, elementWidth) => { 159 | console.log(viewport, top); 160 | }, { 161 | recalculateLayoutBeforeUpdate: () => ref.current ? ref.current.getBoundingClientRect().width : null 162 | }); 163 | 164 | return
; 165 | } 166 | ``` 167 | 168 | ## Related docs 169 | 170 | * [ViewportProvider](./ViewportProvider.md) 171 | * [Types](./types.md) 172 | -------------------------------------------------------------------------------- /docs/api/ViewportProvider.md: -------------------------------------------------------------------------------- 1 | # ViewportProvider 2 | 3 | The ViewportProvider is the heart because it collects and delegates global viewport information to connected components. 4 | 5 | All other components needs to be a child of the `ViewportProvider` to receive events. 6 | 7 | In case you are building libraries, don't worry about having more than one `ViewportProvider` within the tree. The library will detect other `ViewportProvider` and make sure that only on provider will collect and send out events. 8 | 9 | ## API 10 | 11 | | Property | Type | Required? | Description | 12 | |:---|:---|:---:|:---| 13 | | experimentalSchedulerEnabled | boolean | | If set enables the experimental scheduler which allows to make use of the `priority` props on connected components to drop frames if necessary for a smooth user experience. | 14 | | children | ReactNode | ✓ | Any react node that should be rendered. Nested in the tree can be components that connect to viewport updates | 15 | 16 | ## Example 17 | 18 | ``` javascript 19 | import * as React from 'react'; 20 | import { 21 | ViewportProvider, 22 | connectViewport, 23 | } from 'react-viewport-utils'; 24 | 25 | const Component = ({ scroll, dimensions }) => ( 26 | <> 27 |
Dimension (inner)width: ${dimensions.width}
28 |
Dimension (inner)height: ${dimensions.height}
29 |
Scroll X: {scroll.x}
30 |
Scroll Y: {scroll.y}
31 | 32 | ); 33 | const ConnectedComponent = connectViewport()(Component); 34 | 35 | render( 36 | 37 |
38 | 39 |
40 |
, 41 | document.querySelector('main') 42 | ); 43 | ``` 44 | 45 | ## Related docs 46 | 47 | * [Observe the Viewport](./ObserveViewport_connectViewport_useViewport.md) 48 | * [Scheduler](../concepts/scheduler.md) 49 | -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | ## Scroll 4 | 5 | For scroll events the `scroll` object is exposed which contains the following properties. 6 | 7 | | Property | Type | Description | 8 | |:---|:---|:---| 9 | | x | number | Horizontal scroll position | 10 | | y | number | Vertical scroll position | 11 | | xTurn | number | Horizontal scroll position where the scroll dRection turned in the opposite dRection | 12 | | yTurn | number | Vertical scroll position where the scroll dRection turned in the opposite dRection | 13 | | xDTurn | number | Difference of the horizontal scroll position where the scroll dRection turned in the opposite dRection | 14 | | yDTurn | number | Difference of the vertical scroll position where the scroll dRection turned in the opposite dRection | 15 | | isScrollingUp | boolean | Whether the page is scrolling up | 16 | | isScrollingDown | boolean | Whether the page is scrolling down | 17 | | isScrollingLeft | boolean | Whether the page is scrolling left | 18 | | isScrollingRight | boolean | Whether the page is scrolling right | 19 | 20 | ## Dimensions 21 | 22 | | Property | Type | Description | 23 | |:---|:---|:---| 24 | | width | number | Inner width of the `window` | 25 | | height | number | Inner height of the `window` | 26 | | outerWidth | Outer width of the `window` | 27 | | outerHeight | Outer height of the `window` | 28 | | clientWidth | number | Width of the document element | 29 | | clientHeight | number | Height of the document element | 30 | | documentWidth | number | Complete width of the document | 31 | | documentHeight | number | Complete height of the document | 32 | 33 | ## Viewport 34 | 35 | | Property | Type | Description | 36 | |:---|:---|:---| 37 | | scroll | Scroll | See Scroll type above | 38 | | dimensions | Dimensions | See Dimensions type above | 39 | 40 | 41 | ## Rect 42 | 43 | | Property | Type | Description | 44 | |:---|:---|:---| 45 | | top | number | Top position of the element, relative to the viewport | 46 | | right | number | Right position of the element, relative to the viewport | 47 | | bottom | number | Bottom position of the element, relative to the viewport | 48 | | left | number | Left position of the element, relative to the viewport | 49 | | width | number | Width of the element | 50 | | height | number | Height of the element | 51 | -------------------------------------------------------------------------------- /docs/api/useRect.md: -------------------------------------------------------------------------------- 1 | # Observe an element 2 | 3 | ## `useRect` 4 | 5 | Returns the rect of the elements, including it's position within the viewport as well as it's size. 6 | 7 | Please note that at the moment the rect will only update after global scroll or resize events. Changes to the element without those interactions will not be observed (e.g. if an animation is performed). 8 | In case you need full control over the element, I recommend using the [ResizeObserver DOM API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). 9 | 10 | **!!! Hooks require a `ViewportProvider` as a parent and only work with react v16.7.0 !!!** 11 | 12 | ### API 13 | 14 | | Argument | Type | Required? | Description | 15 | |:---|:---|:---:|:---| 16 | | ref | React.RefObject\ | x | The reference to an element that should be observed | 17 | | options.disableScrollUpdates | boolean | | Disables updates to scroll events (only for `useViewport`) | 18 | | options.disableDimensionsUpdates | boolean | | Disables updates to dimensions events (only for `useViewport`) | 19 | | options.deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md) | 20 | | options.priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 21 | | deps | array | | Array with dependencies. In case a value inside the array changes, this will force an update on the rect | 22 | 23 | ### Example 24 | 25 | ``` javascript 26 | import * as React from 'react'; 27 | import { useRect } from 'react-viewport-utils'; 28 | 29 | function Component() { 30 | const ref = React.useRef() 31 | const rect = useRect(ref) || { 32 | width: NaN, 33 | height: NaN, 34 | }; 35 | 36 | return ( 37 |
38 | Current Size is {rect.width}x{rect.width} 39 |
40 | ); 41 | } 42 | ``` 43 | 44 | ## `useRectEffect` 45 | 46 | Same as the `useRect` hook but as an effect, therefore it does not return anything and will not re-render the component. This should be used if a side effect should be performed. 47 | 48 | **!!! Hooks require a `ViewportProvider` as a parent and only work with react v16.7.0 !!!** 49 | 50 | ### API 51 | 52 | | Argument | Type | Required? | Description | 53 | |:---|:---|:---:|:---| 54 | | effect | (rect: Rect \| null) => void | x | The side effect that should be performed | 55 | | ref | React.RefObject\ | x | The reference to an element that should be observed | 56 | | options.disableScrollUpdates | boolean | | Disables updates to scroll events (only for `useViewport`) | 57 | | options.disableDimensionsUpdates | boolean | | Disables updates to dimensions events (only for `useViewport`) | 58 | | options.deferUpdateUntilIdle | boolean | | Defers to trigger updates until the collector is idle. See [Defer Events](../concepts/defer_events.md) | 59 | | options.priority | `'low'`, `'normal'`, `'high'`, `'highest'` | | Allows to set a priority of the update. See [Defer Events](../concepts/scheduler.md) | 60 | | deps | array | | Array with dependencies. In case a value inside the array changes, this will force an update to the effect function | 61 | 62 | ### Example 63 | 64 | ``` javascript 65 | import * as React from 'react'; 66 | import { useRectEffect } from 'react-viewport-utils'; 67 | 68 | function Component() { 69 | const ref = React.useRef() 70 | useRectEffect((rect) => console.log(rect), ref) 71 | 72 | return ( 73 |
74 | ); 75 | } 76 | ``` 77 | 78 | ## Related docs 79 | 80 | * [ViewportProvider](./ViewportProvider.md) 81 | * [Types](./types.md) 82 | -------------------------------------------------------------------------------- /docs/concepts/defer_events.md: -------------------------------------------------------------------------------- 1 | # Defer events 2 | 3 | Some updates are heavy and might reduce the user experience when scheduled simultaneously to others. Therefore its possible to defer events until idle by enabling `deferUpdateUntilIdle` (default is `false`). If enabled, the `onUpdate` callback/ the rerender of the component will be deferred until no events (independent whether `omit`, `disableDimensionsUpdates` or `disableScrollUpdates` is used) are scheduled anymore. 4 | 5 | This option is available for 6 | 7 | * ObserveViewport 8 | * connectViewport 9 | * useViewport, useScroll, useDimensions, useLayoutSnapshot 10 | 11 | ## Example 12 | 13 | ``` javascript 14 | 18 | 19 | const ConnectedComponent = connectViewport({ deferUpdateUntilIdle: true })(Component); 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/concepts/recalculateLayoutBeforeUpdate.md: -------------------------------------------------------------------------------- 1 | # `recalculateLayoutBeforeUpdate` 2 | 3 | When an update is triggered, sometimes further calculations on the DOM which might trigger [layouts/ reflows](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) are required to execute a task. 4 | In general the best performance is archive by first reading all the values in one badge and later update the DOM again. With multiple components in one page this can become difficult. 5 | 6 | The optional `recalculateLayoutBeforeUpdate` property, which accepts a function, will allow to exactly handle those reads in one badge for all components to later perform the update: 7 | 8 | * first all `recalculateLayoutBeforeUpdate` functions for all components are executed. 9 | * second all `onUpdate` function are called which receive the value returned from `recalculateLayoutBeforeUpdate` as the second argument. 10 | 11 | This option is available for 12 | 13 | * ObserveViewport 14 | * useLayoutSnapshot 15 | 16 | ## Example 17 | 18 | ``` javascript 19 | el.getBoundingClientRect()} 21 | onUpdate={({ scroll }, rect) => console.log('Top offset: ', scroll.y + rect.top))} 22 | /> 23 | 24 | const Component = () => { 25 | const offsetTop = useLayoutSnapshot( 26 | ({ scroll }) => scroll.y + el.getBoundingClientRect().top 27 | ); 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/concepts/scheduler.md: -------------------------------------------------------------------------------- 1 | ### Experimental Scheduler 2 | 3 | Some updates on the page have higher priority than others, e.g. an animation of a visible element is more important for a good ux than a tracking event that fires after a certain scroll position. For sure, the tracking event must fire at some point in time but it is not important that it fires immediately. 4 | 5 | The scheduler is able to handle those differences. By default it measures the amount of time an update needs in average and might drop some frames in favor of others. To tell which Observers are more important than others it allows to set 4 different levels: `highest`, `high`, `normal` and `low`. 6 | 7 | The scheduler learns over time based on how fast updates are executed, therefore the amount of events called depend heavily on the platform. On a low end device it will fire way less events than on a high end device. 8 | 9 | When the scheduler is disabled, all observers have priority `highest` as default and will therefore never drop frames. Default when enabled is `normal`. 10 | 11 | Its always guaranteed that the observer fires at some point in time with the recent updates but it might drop some frames in between if `priority` is not set to `highest`. 12 | 13 | The scheduler is for now disabled by default and needs to be activated on the `ViewportProvider`. 14 | 15 | **!!! This is an experimental API and its implementation might change in the future !!!** 16 | 17 | ``` javascript 18 | const handleUpdate = ({ scroll, dimensions }: Viewport) { 19 | console.log(scroll, dimensions); 20 | } 21 | 22 | render( 23 | 24 | 25 | 26 | , 27 | document.querySelector('main') 28 | ); 29 | ``` 30 | 31 | The priority of an observer can be updated at runtime which allows to update priority e.g. for elements that are not visible at the moment. 32 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import { 5 | ViewportProvider, 6 | ObserveViewport, 7 | connectViewport, 8 | useScroll, 9 | useLayoutSnapshot, 10 | useDimensions, 11 | useRect, 12 | useScrollEffect, 13 | useDimensionsEffect, 14 | useViewportEffect, 15 | useRectEffect, 16 | useMutableViewport, 17 | } from '../lib/index'; 18 | 19 | import './styles.css'; 20 | 21 | const Placeholder = () =>
; 22 | const ViewportHeader = connectViewport({ omit: ['scroll'] })<{ a: string }>( 23 | ({ dimensions, a }) => ( 24 |
25 | Viewport: {dimensions.width}x{dimensions.height} 26 | {a} 27 | 28 |
29 | ), 30 | ); 31 | 32 | const DisplayViewport = React.memo(() => { 33 | const div = React.useRef(null); 34 | const { x, y } = useScroll({ 35 | priority: 'low', 36 | }); 37 | const { documentHeight, clientWidth } = useDimensions({ 38 | priority: 'low', 39 | }); 40 | const rect = useRect(div); 41 | return ( 42 |
43 | x: {x}, y: {y} 44 |
45 | documentHeight: {documentHeight} 46 |
47 | clientWidth: {clientWidth} 48 |
49 | rect.top: {rect ? rect.top : 'null'}, rect.bottom:{' '} 50 | {rect ? rect.bottom : 'null'} 51 |
52 | ); 53 | }); 54 | 55 | const LayoutSnapshot = () => { 56 | const div = React.useRef(null); 57 | const offsetTop = useLayoutSnapshot(({ scroll }) => { 58 | if (!div.current) { 59 | return 0; 60 | } 61 | return div.current.getBoundingClientRect().top + scroll.y; 62 | }); 63 | console.log('hook:layout snapshot', offsetTop); 64 | return
; 65 | }; 66 | 67 | const LayoutOutside = React.memo(() => { 68 | const [active, setActive] = React.useState(true); 69 | return ( 70 | <> 71 | 72 | {active && } 73 | 74 | ); 75 | }); 76 | 77 | class Example extends React.PureComponent<{}, { disabled: boolean }> { 78 | private container1: React.RefObject; 79 | private container2: React.RefObject; 80 | 81 | constructor(props) { 82 | super(props); 83 | this.container1 = React.createRef(); 84 | this.container2 = React.createRef(); 85 | this.state = { 86 | disabled: false, 87 | }; 88 | } 89 | 90 | renderButton() { 91 | return ( 92 | 95 | ); 96 | } 97 | 98 | lastDimensions = null; 99 | lastScroll = null; 100 | 101 | render() { 102 | if (this.state.disabled) { 103 | return this.renderButton(); 104 | } 105 | return ( 106 | 107 | 108 | { 111 | console.log('ObserveViewport: update scroll only', props.scroll); 112 | }} 113 | /> 114 | { 116 | if (this.lastDimensions !== dimensions) { 117 | console.log('ObserveViewport: update dimensions', dimensions); 118 | this.lastDimensions = dimensions; 119 | } 120 | if (this.lastScroll !== scroll) { 121 | console.log('ObserveViewport: update scroll', scroll); 122 | this.lastScroll = scroll; 123 | } 124 | }} 125 | /> 126 | { 130 | console.log( 131 | 'ObserveViewport: update dimensions lazy', 132 | props.dimensions, 133 | ); 134 | }} 135 | /> 136 | 137 | 138 | {this.renderButton()} 139 | 140 | ); 141 | } 142 | } 143 | 144 | const HooksExample = () => { 145 | const ref = React.useRef(); 146 | useScrollEffect((scroll) => console.log('hook:scroll effect', scroll)); 147 | useDimensionsEffect((dimensions) => 148 | console.log('hook:dimensions effect', dimensions), 149 | ); 150 | useViewportEffect((viewport) => 151 | console.log('hook:viewport effect', viewport), 152 | ); 153 | useRectEffect((rect) => console.log('hook:rect effect', rect), ref); 154 | const viewport = useMutableViewport(); 155 | 156 | React.useEffect(() => { 157 | const id = setInterval(() => { 158 | console.log('hook:mutableViewport', viewport); 159 | }, 1000); 160 | return () => clearInterval(id); 161 | }, [viewport]); 162 | return
; 163 | }; 164 | 165 | render( 166 | 170 |
171 | 172 | 173 | 174 | 175 | 176 | 177 |
178 |
, 179 | document.getElementById('root'), 180 | ); 181 | 182 | setInterval(() => { 183 | render( 184 | 188 |
189 | 190 | 191 | 192 | 193 | 194 | 195 |
196 |
, 197 | document.getElementById('root'), 198 | ); 199 | }, 1000); 200 | -------------------------------------------------------------------------------- /examples/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | .header { 7 | width: 100%; 8 | height: 300px; 9 | background: lime; 10 | position: fixed; 11 | } 12 | 13 | .sticky-inline { 14 | width: 100%; 15 | height: 100px; 16 | background: tomato; 17 | border-bottom: 1px solid white; 18 | } 19 | 20 | .placeholder { 21 | width: 100%; 22 | height: 500px; 23 | background: black; 24 | border-bottom: 5px solid red; 25 | } 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'tsx', 'js'], 3 | testMatch: ['/lib/**/__tests__/*.test.+(ts|tsx|js)'], 4 | preset: 'ts-jest', 5 | globals: { 6 | 'ts-jest': { 7 | tsConfig: './tsconfig.test.json', 8 | diagnostics: false, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /lib/ConnectViewport.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Scroll, Dimensions } from './types'; 4 | import ObserveViewport from './ObserveViewport'; 5 | import { PriorityType } from './types'; 6 | 7 | interface InjectedProps { 8 | scroll?: Scroll | null; 9 | dimensions?: Dimensions | null; 10 | } 11 | 12 | type OmitValues = 'scroll' | 'dimensions'; 13 | 14 | interface IOptions { 15 | omit?: OmitValues[]; 16 | deferUpdateUntilIdle?: boolean; 17 | priority?: PriorityType; 18 | } 19 | 20 | export default function connect(options: IOptions = {}) { 21 | const deferUpdateUntilIdle = Boolean(options.deferUpdateUntilIdle); 22 | const omit = options.omit || []; 23 | const shouldOmitScroll = omit.indexOf('scroll') !== -1; 24 | const shouldOmitDimensions = omit.indexOf('dimensions') !== -1; 25 | return

( 26 | WrappedComponent: React.ComponentType

, 27 | ): React.ComponentClass

=> { 28 | const displayName = 29 | WrappedComponent.displayName || WrappedComponent.name || 'Component'; 30 | return class ConnectViewport extends React.Component { 31 | static displayName = `connectViewport(${displayName})`; 32 | 33 | render() { 34 | return ( 35 | 41 | {({ scroll, dimensions }) => ( 42 | 47 | )} 48 | 49 | ); 50 | } 51 | }; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /lib/ObserveViewport.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ViewportContext } from './ViewportProvider'; 4 | import { 5 | createEmptyDimensionState, 6 | createEmptyScrollState, 7 | } from './ViewportCollector'; 8 | import { 9 | Scroll, 10 | Dimensions, 11 | Viewport, 12 | ViewportChangeHandler, 13 | ViewportChangeOptions, 14 | PriorityType, 15 | } from './types'; 16 | import { 17 | warnNoContextAvailable, 18 | requestAnimationFrame, 19 | cancelAnimationFrame, 20 | } from './utils'; 21 | 22 | interface ChildProps { 23 | scroll: Scroll | null; 24 | dimensions: Dimensions | null; 25 | } 26 | 27 | interface State extends ChildProps {} 28 | 29 | interface Props { 30 | children?: (props: ChildProps) => React.ReactNode; 31 | onUpdate?: (props: ChildProps, layoutSnapshot: unknown) => void; 32 | recalculateLayoutBeforeUpdate?: (props: ChildProps) => unknown; 33 | disableScrollUpdates: boolean; 34 | disableDimensionsUpdates: boolean; 35 | deferUpdateUntilIdle: boolean; 36 | priority: PriorityType; 37 | } 38 | 39 | interface Context { 40 | addViewportChangeListener: ( 41 | handler: ViewportChangeHandler, 42 | options: ViewportChangeOptions, 43 | ) => void; 44 | removeViewportChangeListener: (handler: ViewportChangeHandler) => void; 45 | scheduleReinitializeChangeHandler: (handler: ViewportChangeHandler) => void; 46 | hasRootProviderAsParent: boolean; 47 | getCurrentViewport: () => Viewport; 48 | version: string; 49 | } 50 | 51 | export default class ObserveViewport extends React.Component { 52 | private removeViewportChangeListener?: ( 53 | handler: ViewportChangeHandler, 54 | ) => void; 55 | private scheduleReinitializeChangeHandler?: ( 56 | handler: ViewportChangeHandler, 57 | ) => void; 58 | 59 | private tickId?: number; 60 | private nextViewport?: Viewport; 61 | 62 | static defaultProps: Props = { 63 | disableScrollUpdates: false, 64 | disableDimensionsUpdates: false, 65 | deferUpdateUntilIdle: false, 66 | priority: 'normal', 67 | }; 68 | 69 | constructor(props: Props) { 70 | super(props); 71 | this.state = { 72 | scroll: createEmptyScrollState(), 73 | dimensions: createEmptyDimensionState(), 74 | }; 75 | } 76 | 77 | componentDidUpdate(prevProps: Props) { 78 | const dimensionsBecameActive = 79 | !this.props.disableDimensionsUpdates && 80 | prevProps.disableDimensionsUpdates; 81 | const scrollBecameActive = 82 | !this.props.disableScrollUpdates && prevProps.disableScrollUpdates; 83 | if ( 84 | typeof this.scheduleReinitializeChangeHandler === 'function' && 85 | (dimensionsBecameActive || scrollBecameActive) 86 | ) { 87 | this.scheduleReinitializeChangeHandler(this.handleViewportUpdate); 88 | } 89 | } 90 | 91 | componentWillUnmount() { 92 | if (this.removeViewportChangeListener) { 93 | this.removeViewportChangeListener(this.handleViewportUpdate); 94 | } 95 | this.removeViewportChangeListener = undefined; 96 | this.scheduleReinitializeChangeHandler = undefined; 97 | if (typeof this.tickId === 'number') { 98 | cancelAnimationFrame(this.tickId); 99 | } 100 | } 101 | 102 | handleViewportUpdate = (viewport: Viewport, layoutSnapshot: unknown) => { 103 | if (this.props.onUpdate) { 104 | this.props.onUpdate(viewport, layoutSnapshot); 105 | } 106 | 107 | if (this.props.children) { 108 | this.syncState(viewport); 109 | } 110 | }; 111 | 112 | syncState(nextViewport: Viewport) { 113 | this.nextViewport = nextViewport; 114 | if (this.tickId === undefined) { 115 | this.tickId = requestAnimationFrame(() => { 116 | if (this.nextViewport) { 117 | this.setState(this.nextViewport); 118 | } 119 | this.tickId = undefined; 120 | this.nextViewport = undefined; 121 | }); 122 | } 123 | } 124 | 125 | get optionNotifyScroll(): boolean { 126 | return !this.props.disableScrollUpdates; 127 | } 128 | 129 | get optionNotifyDimensions(): boolean { 130 | return !this.props.disableDimensionsUpdates; 131 | } 132 | 133 | registerViewportListeners = ({ 134 | addViewportChangeListener, 135 | removeViewportChangeListener, 136 | scheduleReinitializeChangeHandler, 137 | hasRootProviderAsParent, 138 | getCurrentViewport, 139 | }: Context): React.ReactNode => { 140 | if (!hasRootProviderAsParent) { 141 | warnNoContextAvailable('ObserveViewport'); 142 | return null; 143 | } 144 | 145 | const shouldRegister = 146 | this.removeViewportChangeListener !== removeViewportChangeListener; 147 | 148 | if (!shouldRegister) { 149 | return null; 150 | } 151 | 152 | if (this.removeViewportChangeListener) { 153 | this.removeViewportChangeListener(this.handleViewportUpdate); 154 | } 155 | 156 | this.removeViewportChangeListener = removeViewportChangeListener; 157 | this.scheduleReinitializeChangeHandler = scheduleReinitializeChangeHandler; 158 | addViewportChangeListener(this.handleViewportUpdate, { 159 | notifyScroll: () => !this.props.disableScrollUpdates, 160 | notifyDimensions: () => !this.props.disableDimensionsUpdates, 161 | notifyOnlyWhenIdle: () => this.props.deferUpdateUntilIdle, 162 | priority: () => this.props.priority, 163 | recalculateLayoutBeforeUpdate: (viewport: Viewport) => { 164 | if (this.props.recalculateLayoutBeforeUpdate) { 165 | return this.props.recalculateLayoutBeforeUpdate(viewport); 166 | } 167 | return null; 168 | }, 169 | }); 170 | 171 | if (this.props.children) { 172 | this.syncState(getCurrentViewport()); 173 | } 174 | 175 | return null; 176 | }; 177 | 178 | render() { 179 | const { children } = this.props; 180 | return ( 181 | 182 | 183 | {this.registerViewportListeners} 184 | 185 | {children ? children(this.state) : null} 186 | 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/ViewportCollector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import memoize from 'memoize-one'; 3 | 4 | import { 5 | shallowEqualScroll, 6 | shallowEqualDimensions, 7 | browserSupportsPassiveEvents, 8 | simpleDebounce, 9 | debounceOnUpdate, 10 | warnNoResizeObserver, 11 | requestAnimationFrame, 12 | cancelAnimationFrame, 13 | } from './utils'; 14 | 15 | import { Dimensions, Scroll, Viewport, OnUpdateType } from './types'; 16 | 17 | export const getClientDimensions = (): Dimensions => { 18 | if (typeof document === 'undefined' || !document.documentElement) { 19 | return createEmptyDimensionState(); 20 | } 21 | const { innerWidth, innerHeight, outerWidth, outerHeight } = window; 22 | const { 23 | clientWidth, 24 | clientHeight, 25 | scrollHeight, 26 | scrollWidth, 27 | offsetHeight, 28 | offsetWidth, 29 | } = document.documentElement; 30 | return { 31 | width: innerWidth, 32 | height: innerHeight, 33 | clientWidth, 34 | clientHeight, 35 | outerWidth, 36 | outerHeight, 37 | documentWidth: Math.max(scrollWidth, offsetWidth, clientWidth), 38 | documentHeight: Math.max(scrollHeight, offsetHeight, clientHeight), 39 | }; 40 | }; 41 | 42 | const getNodeScroll = (elem = window) => { 43 | let { scrollX, scrollY } = elem; 44 | if (scrollX === undefined) { 45 | scrollX = elem.pageXOffset; 46 | } 47 | if (scrollY === undefined) { 48 | scrollY = elem.pageYOffset; 49 | } 50 | 51 | return { 52 | x: scrollX, 53 | y: scrollY, 54 | }; 55 | }; 56 | 57 | export const getClientScroll = ( 58 | prevScrollState: Scroll = createEmptyScrollState(), 59 | ) => { 60 | if (typeof window === 'undefined') { 61 | return createEmptyScrollState(); 62 | } 63 | const { x, y } = getNodeScroll(); 64 | const nextScrollState = { ...prevScrollState }; 65 | const { 66 | isScrollingLeft: prevIsScrollingLeft, 67 | isScrollingUp: prevIsScrollingUp, 68 | xTurn: prevXTurn, 69 | yTurn: prevYTurn, 70 | } = prevScrollState; 71 | 72 | nextScrollState.isScrollingLeft = isScrollingLeft(x, nextScrollState); 73 | nextScrollState.isScrollingRight = isScrollingRight(x, nextScrollState); 74 | 75 | nextScrollState.isScrollingUp = isScrollingUp(y, nextScrollState); 76 | nextScrollState.isScrollingDown = isScrollingDown(y, nextScrollState); 77 | 78 | nextScrollState.xTurn = 79 | nextScrollState.isScrollingLeft === prevIsScrollingLeft ? prevXTurn : x; 80 | nextScrollState.yTurn = 81 | nextScrollState.isScrollingUp === prevIsScrollingUp ? prevYTurn : y; 82 | 83 | nextScrollState.xDTurn = x - nextScrollState.xTurn; 84 | nextScrollState.yDTurn = y - nextScrollState.yTurn; 85 | 86 | nextScrollState.x = x; 87 | nextScrollState.y = y; 88 | 89 | return nextScrollState; 90 | }; 91 | 92 | const isScrollingLeft = (x: number, prev: Scroll) => { 93 | switch (true) { 94 | case x < prev.x: 95 | return true; 96 | case x > prev.x: 97 | return false; 98 | case x === prev.x: 99 | return prev.isScrollingLeft; 100 | default: 101 | throw new Error('Could not calculate isScrollingLeft'); 102 | } 103 | }; 104 | 105 | const isScrollingRight = (x: number, prev: Scroll) => { 106 | switch (true) { 107 | case x > prev.x: 108 | return true; 109 | case x < prev.x: 110 | return false; 111 | case x === prev.x: 112 | return prev.isScrollingRight; 113 | default: 114 | throw new Error('Could not calculate isScrollingRight'); 115 | } 116 | }; 117 | 118 | const isScrollingUp = (y: number, prev: Scroll) => { 119 | switch (true) { 120 | case y < prev.y: 121 | return true; 122 | case y > prev.y: 123 | return false; 124 | case y === prev.y: 125 | return prev.isScrollingUp; 126 | default: 127 | throw new Error('Could not calculate isScrollingUp'); 128 | } 129 | }; 130 | 131 | const isScrollingDown = (y: number, prev: Scroll) => { 132 | switch (true) { 133 | case y > prev.y: 134 | return true; 135 | case y < prev.y: 136 | return false; 137 | case y === prev.y: 138 | return prev.isScrollingDown; 139 | default: 140 | throw new Error('Could not calculate isScrollingDown'); 141 | } 142 | }; 143 | 144 | export const createEmptyScrollState = () => ({ 145 | x: 0, 146 | y: 0, 147 | isScrollingUp: false, 148 | isScrollingDown: false, 149 | isScrollingLeft: false, 150 | isScrollingRight: false, 151 | xTurn: 0, 152 | yTurn: 0, 153 | xDTurn: 0, 154 | yDTurn: 0, 155 | }); 156 | 157 | export const createEmptyDimensionState = (): Dimensions => ({ 158 | width: 0, 159 | height: 0, 160 | clientWidth: 0, 161 | clientHeight: 0, 162 | outerWidth: 0, 163 | outerHeight: 0, 164 | documentWidth: 0, 165 | documentHeight: 0, 166 | }); 167 | 168 | interface IProps { 169 | onUpdate: OnUpdateType; 170 | onIdledUpdate?: OnUpdateType; 171 | } 172 | 173 | export default class ViewportCollector extends React.PureComponent { 174 | public scrollState: Scroll; 175 | public dimensionsState: Dimensions; 176 | private lastSyncedScrollState: Scroll; 177 | private lastSyncedDimensionsState: Dimensions; 178 | private tickId?: number; 179 | private scrollMightHaveUpdated: boolean; 180 | private resizeMightHaveUpdated: boolean; 181 | private resizeObserver: ResizeObserver | null; 182 | public syncedStateOnce: boolean; 183 | 184 | constructor(props: IProps) { 185 | super(props); 186 | this.state = { 187 | parentProviderExists: false, 188 | }; 189 | this.scrollMightHaveUpdated = false; 190 | this.resizeMightHaveUpdated = false; 191 | this.scrollState = createEmptyScrollState(); 192 | this.dimensionsState = createEmptyDimensionState(); 193 | this.lastSyncedDimensionsState = { ...this.dimensionsState }; 194 | this.lastSyncedScrollState = { ...this.scrollState }; 195 | this.resizeObserver = null; 196 | this.syncedStateOnce = false; 197 | } 198 | 199 | componentDidMount() { 200 | const options = browserSupportsPassiveEvents ? { passive: true } : false; 201 | window.addEventListener('scroll', this.handleScroll, options); 202 | window.addEventListener('resize', this.handleResizeDebounce, options); 203 | window.addEventListener( 204 | 'orientationchange', 205 | this.handleResizeDebounce, 206 | options, 207 | ); 208 | 209 | if (typeof window.ResizeObserver !== 'undefined') { 210 | this.resizeObserver = new window.ResizeObserver( 211 | this.handleResizeDebounce, 212 | ); 213 | this.resizeObserver!.observe(document.body); 214 | } else { 215 | warnNoResizeObserver(); 216 | } 217 | 218 | this.handleScroll(); 219 | this.handleResize(); 220 | } 221 | 222 | componentWillUnmount() { 223 | window.removeEventListener('scroll', this.handleScroll, false); 224 | window.removeEventListener('resize', this.handleResizeDebounce, false); 225 | window.removeEventListener( 226 | 'orientationchange', 227 | this.handleResizeDebounce, 228 | false, 229 | ); 230 | if (this.resizeObserver) { 231 | this.resizeObserver.disconnect(); 232 | this.resizeObserver = null; 233 | } 234 | if (typeof this.tickId === 'number') { 235 | cancelAnimationFrame(this.tickId); 236 | } 237 | } 238 | 239 | tick = () => { 240 | if (this) { 241 | if (this.scrollMightHaveUpdated || this.resizeMightHaveUpdated) { 242 | this.syncState(); 243 | this.scrollMightHaveUpdated = false; 244 | this.resizeMightHaveUpdated = false; 245 | } 246 | this.tickId = undefined; 247 | } 248 | }; 249 | 250 | handleScroll = () => { 251 | this.scrollMightHaveUpdated = true; 252 | if (!this.tickId) { 253 | this.tickId = requestAnimationFrame(this.tick); 254 | } 255 | }; 256 | 257 | handleResize = () => { 258 | this.resizeMightHaveUpdated = true; 259 | if (!this.tickId) { 260 | this.tickId = requestAnimationFrame(this.tick); 261 | } 262 | }; 263 | 264 | handleResizeDebounce = simpleDebounce(this.handleResize, 88); 265 | 266 | getPublicScroll = memoize( 267 | (scroll: Scroll): Scroll => ({ ...scroll }), 268 | ([a]: Array, [b]: Array) => shallowEqualScroll(a, b), 269 | ); 270 | 271 | getPublicDimensions = memoize( 272 | (dimensions: Dimensions): Dimensions => ({ ...dimensions }), 273 | ([a]: Array, [b]: Array) => 274 | shallowEqualDimensions(a, b), 275 | ); 276 | 277 | syncState = () => { 278 | if (!this.syncedStateOnce) { 279 | this.syncedStateOnce = true; 280 | } 281 | if (this.scrollMightHaveUpdated) { 282 | Object.assign(this.scrollState, getClientScroll(this.scrollState)); 283 | } 284 | if (this.resizeMightHaveUpdated) { 285 | Object.assign(this.dimensionsState, getClientDimensions()); 286 | } 287 | const scrollDidUpdate = 288 | this.scrollMightHaveUpdated && 289 | !shallowEqualScroll(this.lastSyncedScrollState, this.scrollState); 290 | const dimensionsDidUpdate = 291 | this.resizeMightHaveUpdated && 292 | !shallowEqualDimensions( 293 | this.lastSyncedDimensionsState, 294 | this.dimensionsState, 295 | ); 296 | 297 | if (scrollDidUpdate) { 298 | this.lastSyncedScrollState = { ...this.scrollState }; 299 | } 300 | 301 | if (dimensionsDidUpdate) { 302 | this.lastSyncedDimensionsState = { ...this.dimensionsState }; 303 | } 304 | 305 | if (scrollDidUpdate || dimensionsDidUpdate) { 306 | const publicState = this.getPropsFromState(); 307 | this.props.onUpdate(publicState, { 308 | scrollDidUpdate, 309 | dimensionsDidUpdate, 310 | }); 311 | this.updateOnIdle(publicState, { 312 | scrollDidUpdate, 313 | dimensionsDidUpdate, 314 | }); 315 | } 316 | }; 317 | 318 | updateOnIdle = debounceOnUpdate((...args) => { 319 | if (typeof this.props.onIdledUpdate === 'function') { 320 | this.props.onIdledUpdate(...args); 321 | } 322 | }, 166); 323 | 324 | getPropsFromState(): Viewport { 325 | return { 326 | scroll: this.getPublicScroll(this.lastSyncedScrollState), 327 | dimensions: this.getPublicDimensions(this.lastSyncedDimensionsState), 328 | }; 329 | } 330 | 331 | render() { 332 | return null; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /lib/ViewportProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { 4 | ViewportChangeHandler, 5 | ViewportChangeOptions, 6 | Viewport, 7 | ViewportCollectorUpdateOptions, 8 | } from './types'; 9 | import ViewportCollector, { 10 | createEmptyDimensionState, 11 | createEmptyScrollState, 12 | getClientDimensions, 13 | getClientScroll, 14 | } from './ViewportCollector'; 15 | import { 16 | createPerformanceMarker, 17 | now, 18 | requestAnimationFrame, 19 | cancelAnimationFrame, 20 | } from './utils'; 21 | 22 | interface Props { 23 | experimentalSchedulerEnabled?: boolean; 24 | experimentalSchedulerLayoutCalculatorEnabled?: boolean; 25 | children?: React.ReactNode; 26 | } 27 | 28 | interface Listener extends ViewportChangeOptions { 29 | handler: ViewportChangeHandler; 30 | iterations: number; 31 | initialized: boolean; 32 | averageExecutionCost: number; 33 | skippedIterations: number; 34 | } 35 | 36 | const createFallbackViewportRequester = () => { 37 | let defaultValue: Viewport; 38 | let lastAccess = 0; 39 | return (): Viewport => { 40 | if (!defaultValue || now() - lastAccess > 1000) { 41 | defaultValue = { 42 | scroll: getClientScroll(), 43 | dimensions: getClientDimensions(), 44 | }; 45 | lastAccess = now(); 46 | } 47 | return defaultValue; 48 | }; 49 | }; 50 | 51 | export const ViewportContext = React.createContext({ 52 | removeViewportChangeListener: (handler: ViewportChangeHandler) => {}, 53 | scheduleReinitializeChangeHandler: (handler: ViewportChangeHandler) => {}, 54 | addViewportChangeListener: ( 55 | handler: ViewportChangeHandler, 56 | options: ViewportChangeOptions, 57 | ) => {}, 58 | getCurrentViewport: createFallbackViewportRequester(), 59 | getMutableViewportState: (): Readonly => ({ 60 | dimensions: createEmptyDimensionState(), 61 | scroll: createEmptyScrollState(), 62 | }), 63 | hasRootProviderAsParent: false, 64 | version: '_VERS_', 65 | }); 66 | 67 | const maxIterations = (priority: 'highest' | 'high' | 'normal' | 'low') => { 68 | switch (priority) { 69 | case 'highest': 70 | return 0; 71 | case 'high': 72 | return 4; 73 | case 'normal': 74 | return 16; 75 | case 'low': 76 | return 64; 77 | } 78 | }; 79 | 80 | const shouldSkipIteration = ( 81 | { priority: getPriority, averageExecutionCost, skippedIterations }: Listener, 82 | budget: number, 83 | ): boolean => { 84 | const priority = getPriority(); 85 | if (priority === 'highest') { 86 | return false; 87 | } 88 | if (priority !== 'low' && averageExecutionCost <= budget) { 89 | return false; 90 | } 91 | if (averageExecutionCost <= budget / 10) { 92 | return false; 93 | } 94 | const probability = skippedIterations / maxIterations(priority); 95 | if (probability >= 1) { 96 | return false; 97 | } 98 | return Math.random() > probability; 99 | }; 100 | 101 | export default class ViewportProvider extends React.PureComponent< 102 | Props, 103 | { hasListeners: boolean } 104 | > { 105 | static defaultProps: { 106 | experimentalSchedulerEnabled: false; 107 | experimentalSchedulerLayoutCalculatorEnabled: false; 108 | }; 109 | private listeners: Listener[] = []; 110 | private updateListenersTick?: NodeJS.Timer; 111 | private initializeListenersTick?: number; 112 | private mutableViewportState: Viewport; 113 | 114 | constructor(props: Props) { 115 | super(props); 116 | this.state = { 117 | hasListeners: false, 118 | }; 119 | const getDimensions = () => { 120 | return ( 121 | this.collector.current?.dimensionsState ?? createEmptyDimensionState() 122 | ); 123 | }; 124 | const getScroll = () => { 125 | return this.collector.current?.scrollState ?? createEmptyScrollState(); 126 | }; 127 | this.mutableViewportState = { 128 | get scroll() { 129 | return getScroll(); 130 | }, 131 | get dimensions() { 132 | return getDimensions(); 133 | }, 134 | }; 135 | } 136 | 137 | componentWillUnmount() { 138 | if (typeof this.updateListenersTick === 'number') { 139 | clearTimeout(this.updateListenersTick); 140 | } 141 | if (typeof this.initializeListenersTick === 'number') { 142 | cancelAnimationFrame(this.initializeListenersTick); 143 | } 144 | } 145 | 146 | triggerUpdateToListeners = ( 147 | state: Viewport, 148 | { scrollDidUpdate, dimensionsDidUpdate }: ViewportCollectorUpdateOptions, 149 | options?: { isIdle?: boolean; shouldInitialize?: boolean }, 150 | ) => { 151 | const getOverallDuration = createPerformanceMarker(); 152 | const { isIdle, shouldInitialize } = Object.assign( 153 | { isIdle: false, shouldInitialize: false }, 154 | options, 155 | ); 156 | let updatableListeners = this.listeners.filter( 157 | ({ 158 | notifyScroll, 159 | notifyDimensions, 160 | notifyOnlyWhenIdle, 161 | skippedIterations, 162 | initialized, 163 | }) => { 164 | const needsUpdate = skippedIterations > 0; 165 | if (notifyOnlyWhenIdle() !== isIdle && !needsUpdate) { 166 | return false; 167 | } 168 | if (shouldInitialize && !initialized) { 169 | return true; 170 | } 171 | const updateForScroll = notifyScroll() && scrollDidUpdate; 172 | const updateForDimensions = notifyDimensions() && dimensionsDidUpdate; 173 | return updateForScroll || updateForDimensions; 174 | }, 175 | ); 176 | if (this.props.experimentalSchedulerEnabled) { 177 | if (!isIdle) { 178 | const budget = 16 / updatableListeners.length; 179 | updatableListeners = updatableListeners.filter((listener) => { 180 | const skip = listener.initialized 181 | ? shouldSkipIteration(listener, budget) 182 | : false; 183 | if (skip) { 184 | listener.skippedIterations++; 185 | return false; 186 | } 187 | listener.skippedIterations = 0; 188 | return true; 189 | }); 190 | } 191 | } 192 | const layouts = updatableListeners.map( 193 | ({ recalculateLayoutBeforeUpdate }) => { 194 | if (recalculateLayoutBeforeUpdate) { 195 | const getDuration = createPerformanceMarker(); 196 | const layoutState = recalculateLayoutBeforeUpdate(state); 197 | return [layoutState, getDuration()] as const; 198 | } 199 | return null; 200 | }, 201 | ); 202 | let overallJSHandlerTotalCost = 0; 203 | updatableListeners.forEach((listener, index) => { 204 | const { handler, averageExecutionCost, iterations } = listener; 205 | const [layout, layoutCost] = layouts[index] || [null, 0]; 206 | 207 | const getDuration = createPerformanceMarker(); 208 | handler(state, layout); 209 | const totalCost = layoutCost + getDuration(); 210 | const diff = totalCost - averageExecutionCost; 211 | const i = iterations + 1; 212 | 213 | listener.averageExecutionCost = averageExecutionCost + diff / i; 214 | listener.iterations = i; 215 | listener.initialized = true; 216 | overallJSHandlerTotalCost += totalCost; 217 | }); 218 | if ( 219 | this.props.experimentalSchedulerLayoutCalculatorEnabled && 220 | updatableListeners.length 221 | ) { 222 | setTimeout(() => { 223 | const diffPerHandler = 224 | (getOverallDuration() - overallJSHandlerTotalCost) / 225 | updatableListeners.length; 226 | updatableListeners.forEach((listener) => { 227 | listener.averageExecutionCost = 228 | listener.averageExecutionCost + 229 | diffPerHandler / listener.iterations; 230 | }); 231 | }, 0); 232 | } 233 | }; 234 | 235 | addViewportChangeListener = ( 236 | handler: ViewportChangeHandler, 237 | options: ViewportChangeOptions, 238 | ) => { 239 | this.listeners.push({ 240 | handler, 241 | iterations: 0, 242 | averageExecutionCost: 0, 243 | skippedIterations: 0, 244 | initialized: false, 245 | ...options, 246 | }); 247 | this.handleListenerUpdate(); 248 | }; 249 | 250 | scheduleReinitializeChangeHandler = (h: ViewportChangeHandler) => { 251 | const listener = this.listeners.find(({ handler }) => handler === h); 252 | if (listener && listener.initialized) { 253 | listener.initialized = false; 254 | this.handleListenerUpdate(); 255 | } 256 | }; 257 | 258 | removeViewportChangeListener = (h: ViewportChangeHandler) => { 259 | this.listeners = this.listeners.filter(({ handler }) => handler !== h); 260 | this.handleListenerUpdate(); 261 | }; 262 | 263 | handleListenerUpdate() { 264 | if (this.updateListenersTick === undefined) { 265 | this.updateListenersTick = setTimeout(() => { 266 | const nextState = this.listeners.length !== 0; 267 | if (this.state.hasListeners !== nextState) { 268 | this.setState({ 269 | hasListeners: this.listeners.length !== 0, 270 | }); 271 | } 272 | this.updateListenersTick = undefined; 273 | }, 1); 274 | } 275 | if (this.initializeListenersTick === undefined) { 276 | this.initializeListenersTick = requestAnimationFrame(() => { 277 | if ( 278 | this.collector.current && 279 | this.collector.current.syncedStateOnce && 280 | this.listeners.some((l) => !l.initialized) 281 | ) { 282 | this.triggerUpdateToListeners( 283 | this.collector.current.getPropsFromState(), 284 | { 285 | dimensionsDidUpdate: false, 286 | scrollDidUpdate: false, 287 | }, 288 | { 289 | isIdle: false, 290 | shouldInitialize: true, 291 | }, 292 | ); 293 | } 294 | this.initializeListenersTick = undefined; 295 | }); 296 | } 297 | } 298 | 299 | private collector = React.createRef(); 300 | private getCurrentDefaultViewport = createFallbackViewportRequester(); 301 | private contextValue = { 302 | addViewportChangeListener: this.addViewportChangeListener, 303 | removeViewportChangeListener: this.removeViewportChangeListener, 304 | scheduleReinitializeChangeHandler: this.scheduleReinitializeChangeHandler, 305 | getCurrentViewport: () => { 306 | if (this.collector.current && this.collector.current.syncedStateOnce) { 307 | return this.collector.current.getPropsFromState(); 308 | } 309 | return this.getCurrentDefaultViewport(); 310 | }, 311 | getMutableViewportState: () => this.mutableViewportState, 312 | hasRootProviderAsParent: true, 313 | version: '_VERS_', 314 | }; 315 | 316 | renderChildren = (props: { 317 | hasRootProviderAsParent: boolean; 318 | version: string; 319 | }) => { 320 | if (props.hasRootProviderAsParent) { 321 | if (process.env.NODE_ENV !== 'production' && props.version !== '_VERS_') { 322 | console.warn( 323 | `react-viewport-utils: Two different versions of the react-viewport-utils library are used in the same react tree. This can lead to unexpected results as the versions might not be compatible. 324 | The of version ${props.version} is currently used, another of version _VERS_ was detected but is ignored. 325 | This is most probably due to some dependencies that use different versions of the react-viewport-utils library. You can check if an update is possible.`, 326 | ); 327 | } 328 | return this.props.children; 329 | } 330 | return ( 331 | 332 | {this.state.hasListeners && ( 333 | 337 | this.triggerUpdateToListeners(state, updates, { isIdle: true }) 338 | } 339 | /> 340 | )} 341 | 342 | {this.props.children} 343 | 344 | 345 | ); 346 | }; 347 | 348 | render() { 349 | return ( 350 | {this.renderChildren} 351 | ); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /lib/__tests__/ObserveViewport.client.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | // force fallback to setTimeout 6 | // @ts-ignore 7 | delete window.requestAnimationFrame; 8 | // @ts-ignore 9 | delete window.cancelAnimationFrame; 10 | jest.useFakeTimers(); 11 | 12 | import React from 'react'; 13 | import { render, cleanup, fireEvent } from '@testing-library/react'; 14 | import { ViewportProvider, ObserveViewport } from '../index'; 15 | 16 | const App = ({ 17 | renderFnMock, 18 | updateFnMock, 19 | }: { 20 | renderFnMock?: () => null; 21 | updateFnMock?: () => null; 22 | }) => { 23 | const [disableScroll, updateDisableScroll] = React.useState(false); 24 | const [disableDimensions, updateDisableDimensions] = React.useState(false); 25 | return ( 26 | 27 | 30 | 33 | 38 | {renderFnMock} 39 | 40 | 41 | ); 42 | }; 43 | 44 | describe('ObserveViewport', () => { 45 | beforeEach(() => { 46 | const eventMap: any = { 47 | scroll: jest.fn(), 48 | }; 49 | jest.spyOn(window, 'addEventListener').mockImplementation((event, cb) => { 50 | eventMap[event] = cb; 51 | }); 52 | 53 | jest 54 | .spyOn(window, 'scrollTo') 55 | .mockImplementation((x: number, y: number) => { 56 | (window as any).scrollX = x; 57 | (window as any).scrollY = y; 58 | eventMap.scroll && eventMap.scroll(); 59 | }); 60 | }); 61 | 62 | afterEach(() => { 63 | cleanup(); 64 | (window.addEventListener as jest.Mock).mockRestore(); 65 | jest.clearAllTimers(); 66 | (window as any).scrollX = 0; 67 | (window as any).scrollY = 0; 68 | }); 69 | 70 | it('should trigger initial scroll value', () => { 71 | const renderFnMock = jest.fn(() => null); 72 | window.scrollTo(0, 1000); 73 | render(); 74 | 75 | jest.advanceTimersByTime(1000); 76 | 77 | expect(renderFnMock).toHaveBeenLastCalledWith( 78 | expect.objectContaining({ 79 | scroll: expect.objectContaining({ 80 | y: 1000, 81 | x: 0, 82 | }), 83 | }), 84 | ); 85 | }); 86 | 87 | it('should trigger changed values after scroll event', () => { 88 | const renderFnMock = jest.fn(() => null); 89 | window.scrollTo(0, 1000); 90 | render(); 91 | jest.advanceTimersByTime(1000); 92 | 93 | expect(renderFnMock).toHaveBeenLastCalledWith( 94 | expect.objectContaining({ 95 | scroll: expect.objectContaining({ 96 | y: 1000, 97 | x: 0, 98 | }), 99 | }), 100 | ); 101 | 102 | window.scrollTo(0, 500); 103 | 104 | jest.advanceTimersByTime(1000); 105 | expect(renderFnMock).toHaveBeenLastCalledWith( 106 | expect.objectContaining({ 107 | scroll: expect.objectContaining({ 108 | y: 500, 109 | x: 0, 110 | }), 111 | }), 112 | ); 113 | }); 114 | 115 | it('should not trigger changed values over time if disabled', () => { 116 | const renderFnMock = jest.fn(() => null); 117 | window.scrollTo(0, 1000); 118 | const { getByText } = render(); 119 | jest.advanceTimersByTime(1000); 120 | 121 | expect(renderFnMock).toHaveBeenLastCalledWith( 122 | expect.objectContaining({ 123 | scroll: expect.objectContaining({ 124 | y: 1000, 125 | x: 0, 126 | }), 127 | }), 128 | ); 129 | 130 | fireEvent.click(getByText('Toggle scroll')); 131 | 132 | window.scrollTo(0, 500); 133 | 134 | jest.advanceTimersByTime(1000); 135 | expect(renderFnMock).not.toHaveBeenLastCalledWith( 136 | expect.objectContaining({ 137 | scroll: expect.objectContaining({ 138 | y: 500, 139 | x: 0, 140 | }), 141 | }), 142 | ); 143 | }); 144 | 145 | it('should trigger update when toggling from active to inactive for render function', () => { 146 | const renderFnMock = jest.fn(() => null); 147 | window.scrollTo(0, 1000); 148 | const { getByText } = render(); 149 | jest.advanceTimersByTime(1000); 150 | 151 | expect(renderFnMock).toHaveBeenLastCalledWith( 152 | expect.objectContaining({ 153 | scroll: expect.objectContaining({ 154 | y: 1000, 155 | x: 0, 156 | }), 157 | }), 158 | ); 159 | 160 | fireEvent.click(getByText('Toggle scroll')); 161 | 162 | window.scrollTo(0, 500); 163 | 164 | jest.advanceTimersByTime(1000); 165 | expect(renderFnMock).not.toHaveBeenLastCalledWith( 166 | expect.objectContaining({ 167 | scroll: expect.objectContaining({ 168 | y: 500, 169 | x: 0, 170 | }), 171 | }), 172 | ); 173 | 174 | fireEvent.click(getByText('Toggle scroll')); 175 | 176 | jest.advanceTimersByTime(1000); 177 | expect(renderFnMock).toHaveBeenLastCalledWith( 178 | expect.objectContaining({ 179 | scroll: expect.objectContaining({ 180 | y: 500, 181 | x: 0, 182 | }), 183 | }), 184 | ); 185 | }); 186 | 187 | it('should trigger update when toggling from active to inactive for onUpdate function', () => { 188 | const updateFnMock = jest.fn(() => null); 189 | window.scrollTo(0, 1000); 190 | const { getByText } = render(); 191 | jest.advanceTimersByTime(1000); 192 | 193 | expect(updateFnMock).toHaveBeenLastCalledWith( 194 | expect.objectContaining({ 195 | scroll: expect.objectContaining({ 196 | y: 1000, 197 | x: 0, 198 | }), 199 | }), 200 | null, 201 | ); 202 | 203 | fireEvent.click(getByText('Toggle scroll')); 204 | 205 | window.scrollTo(0, 500); 206 | 207 | jest.advanceTimersByTime(1000); 208 | expect(updateFnMock).not.toHaveBeenLastCalledWith( 209 | expect.objectContaining({ 210 | scroll: expect.objectContaining({ 211 | y: 500, 212 | x: 0, 213 | }), 214 | }), 215 | null, 216 | ); 217 | 218 | fireEvent.click(getByText('Toggle scroll')); 219 | 220 | jest.advanceTimersByTime(1000); 221 | expect(updateFnMock).toHaveBeenLastCalledWith( 222 | expect.objectContaining({ 223 | scroll: expect.objectContaining({ 224 | y: 500, 225 | x: 0, 226 | }), 227 | }), 228 | null, 229 | ); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /lib/__tests__/hooks.client.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | // force fallback to setTimeout 6 | // @ts-ignore 7 | delete window.requestAnimationFrame; 8 | jest.useFakeTimers(); 9 | 10 | import React, { useState, useRef } from 'react'; 11 | import { act } from 'react-dom/test-utils'; 12 | import { render, cleanup, fireEvent } from '@testing-library/react'; 13 | import { ViewportProvider, useViewport, useLayoutSnapshot } from '../index'; 14 | 15 | const scrollTo = (x: number, y: number) => { 16 | window.scrollTo(x, y); 17 | act(() => { 18 | jest.advanceTimersByTime(20); 19 | }); 20 | }; 21 | 22 | describe('hooks', () => { 23 | beforeEach(() => { 24 | const eventMap: any = { 25 | scroll: jest.fn(), 26 | }; 27 | jest.spyOn(window, 'addEventListener').mockImplementation((event, cb) => { 28 | eventMap[event] = cb; 29 | }); 30 | 31 | jest 32 | .spyOn(window, 'scrollTo') 33 | .mockImplementation((x: number, y: number) => { 34 | (window as any).scrollX = x; 35 | (window as any).scrollY = y; 36 | eventMap.scroll && eventMap.scroll(); 37 | }); 38 | }); 39 | 40 | afterEach(() => { 41 | cleanup(); 42 | jest.clearAllTimers(); 43 | (window as any).scrollX = 0; 44 | (window as any).scrollY = 0; 45 | }); 46 | 47 | describe('useViewport', () => { 48 | it('should update on viewport change', async () => { 49 | const App = () => { 50 | const viewport = useViewport(); 51 | return ( 52 |

53 | scroll: {viewport.scroll.x},{viewport.scroll.y} 54 |
55 | ); 56 | }; 57 | const { getByText } = render( 58 | 59 | 60 | , 61 | ); 62 | act(() => { 63 | jest.advanceTimersByTime(20); 64 | }); 65 | scrollTo(0, 1000); 66 | expect(getByText('scroll: 0,1000')).toBeDefined(); 67 | 68 | scrollTo(0, 2000); 69 | expect(getByText('scroll: 0,2000')).toBeDefined(); 70 | }); 71 | 72 | it('should not update if disabled', async () => { 73 | const App = () => { 74 | const viewport = useViewport({ 75 | disableScrollUpdates: true, 76 | }); 77 | return ( 78 |
79 | scroll: {viewport.scroll.x},{viewport.scroll.y} 80 |
81 | ); 82 | }; 83 | const { rerender, getByText } = render(); 84 | scrollTo(0, 1000); 85 | rerender( 86 | 87 | 88 | , 89 | ); 90 | act(() => { 91 | jest.advanceTimersByTime(20); 92 | }); 93 | expect(getByText('scroll: 0,1000')).toBeDefined(); 94 | scrollTo(0, 2000); 95 | expect(getByText('scroll: 0,1000')).toBeDefined(); 96 | }); 97 | 98 | it('should not update if disabled at runtime', async () => { 99 | const App = () => { 100 | const [disableScrollUpdates, setDisableScrollUpdate] = useState(false); 101 | const viewport = useViewport({ 102 | disableScrollUpdates, 103 | }); 104 | return ( 105 |
setDisableScrollUpdate(!disableScrollUpdates)}> 106 | scroll: {viewport.scroll.x},{viewport.scroll.y} 107 |
108 | ); 109 | }; 110 | const { rerender, getByText } = render(); 111 | scrollTo(0, 1000); 112 | rerender( 113 | 114 | 115 | , 116 | ); 117 | act(() => { 118 | jest.advanceTimersByTime(20); 119 | }); 120 | expect(getByText('scroll: 0,1000')).toBeDefined(); 121 | 122 | // disable 123 | fireEvent.click(getByText('scroll: 0,1000')); 124 | scrollTo(0, 2000); 125 | expect(getByText('scroll: 0,1000')).toBeDefined(); 126 | 127 | // enable 128 | fireEvent.click(getByText('scroll: 0,1000')); 129 | 130 | scrollTo(0, 3000); 131 | expect(getByText('scroll: 0,3000')).toBeDefined(); 132 | }); 133 | }); 134 | 135 | describe('useLayoutSnapshot', () => { 136 | it('should update snapshot on scroll', () => { 137 | const App = () => { 138 | const ref = useRef(null); 139 | const snapshot = useLayoutSnapshot(({ scroll }) => { 140 | if (ref.current) { 141 | return `${ref.current.dataset.info},${scroll.y}`; 142 | } 143 | return null; 144 | }); 145 | return ( 146 |
147 | {snapshot} 148 |
149 | ); 150 | }; 151 | const { getByText } = render( 152 | 153 | 154 | , 155 | ); 156 | scrollTo(0, 1000); 157 | act(() => { 158 | jest.advanceTimersByTime(20); 159 | }); 160 | expect(getByText('pony,1000')).toBeDefined(); 161 | }); 162 | 163 | it('should update snapshot on dependency change', () => { 164 | const App = ({ info }: { info: string }) => { 165 | const ref = useRef(null); 166 | const snapshot = useLayoutSnapshot( 167 | ({ scroll }) => { 168 | if (ref.current) { 169 | return `${ref.current.dataset.info},${scroll.y}`; 170 | } 171 | return null; 172 | }, 173 | [info], 174 | ); 175 | return ( 176 |
177 | {snapshot} 178 |
179 | ); 180 | }; 181 | const { getByText, rerender } = render( 182 | 183 | 184 | , 185 | ); 186 | act(() => { 187 | jest.advanceTimersByTime(20); 188 | }); 189 | expect(getByText('pony,0')).toBeDefined(); 190 | rerender( 191 | 192 | 193 | , 194 | ); 195 | act(() => { 196 | jest.advanceTimersByTime(20); 197 | }); 198 | expect(getByText('foo,0')).toBeDefined(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /lib/__tests__/server.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import * as React from 'react'; 5 | import ReactDOMServer from 'react-dom/server'; 6 | import { 7 | ViewportProvider, 8 | connectViewport, 9 | ObserveViewport, 10 | } from '../index'; 11 | import { 12 | useViewport, 13 | useScroll, 14 | useDimensions, 15 | useLayoutSnapshot, 16 | useMutableViewport, 17 | } from '../hooks'; 18 | 19 | describe('server side rendering', () => { 20 | const render = () => { 21 | const TestConnectViewport = connectViewport()(() => null); 22 | const TestHooks = () => { 23 | const ref = React.useRef(null); 24 | useScroll(); 25 | useDimensions(); 26 | useViewport(); 27 | useLayoutSnapshot(() => null); 28 | useMutableViewport() 29 | return
; 30 | }; 31 | return ReactDOMServer.renderToString( 32 | 33 | 34 | 35 | {() => null} 36 | , 37 | ); 38 | }; 39 | 40 | it('should not throw', () => { 41 | expect(render).not.toThrow(); 42 | }); 43 | 44 | it('should render components as if they would have been disabled', () => { 45 | expect(render()).toBe('
'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /lib/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { warnNoContextAvailable } from '../utils'; 2 | 3 | describe('utils', () => { 4 | describe('warnNoContextAvailable', () => { 5 | let warn: any; 6 | beforeEach(() => { 7 | warn = jest.spyOn(console, 'warn'); 8 | }); 9 | 10 | afterEach(() => { 11 | warn.mockRestore(); 12 | }); 13 | 14 | it('should warn for components', () => { 15 | warnNoContextAvailable('ViewportObserver'); 16 | expect(warn).toHaveBeenCalled(); 17 | expect(warn.mock.calls[0][0]).toContain( 18 | 'react-viewport-utils: ViewportObserver component', 19 | ); 20 | }); 21 | 22 | it('should warn for hools', () => { 23 | warnNoContextAvailable('useViewport'); 24 | expect(warn).toHaveBeenCalled(); 25 | expect(warn.mock.calls[0][0]).toContain( 26 | 'react-viewport-utils: useViewport hook', 27 | ); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /lib/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useContext, 3 | useEffect, 4 | useState, 5 | RefObject, 6 | MutableRefObject, 7 | useRef, 8 | DependencyList, 9 | } from 'react'; 10 | 11 | import { ViewportContext } from './ViewportProvider'; 12 | import { Viewport, Scroll, Dimensions, PriorityType, Rect } from './types'; 13 | import { warnNoContextAvailable } from './utils'; 14 | 15 | interface ViewPortEffectOptions extends FullOptions { 16 | recalculateLayoutBeforeUpdate?: (viewport: Viewport) => T; 17 | } 18 | 19 | interface FullOptions extends IOptions { 20 | disableScrollUpdates?: boolean; 21 | disableDimensionsUpdates?: boolean; 22 | } 23 | 24 | interface IOptions { 25 | [key: string]: unknown; 26 | deferUpdateUntilIdle?: boolean; 27 | priority?: PriorityType; 28 | } 29 | interface IEffectOptions extends IOptions { 30 | recalculateLayoutBeforeUpdate?: (viewport: Viewport) => T; 31 | } 32 | 33 | export function useViewportEffect( 34 | handleViewportChange: (viewport: Viewport, snapshot: T) => void, 35 | deps?: DependencyList, 36 | ): void; 37 | 38 | export function useViewportEffect( 39 | handleViewportChange: (viewport: Viewport, snapshot: T) => void, 40 | options?: ViewPortEffectOptions, 41 | deps?: DependencyList, 42 | ): void; 43 | 44 | export function useViewportEffect( 45 | handleViewportChange: (viewport: Viewport, snapshot: T) => void, 46 | second?: any, 47 | third?: any, 48 | ) { 49 | const { 50 | addViewportChangeListener, 51 | removeViewportChangeListener, 52 | hasRootProviderAsParent, 53 | } = useContext(ViewportContext); 54 | const { options, deps } = sortArgs(second, third); 55 | const memoOptions = useOptions(options); 56 | 57 | useEffect(() => { 58 | if (!hasRootProviderAsParent) { 59 | warnNoContextAvailable('useViewport'); 60 | return; 61 | } 62 | addViewportChangeListener(handleViewportChange, { 63 | notifyScroll: () => !memoOptions.disableScrollUpdates, 64 | notifyDimensions: () => !memoOptions.disableDimensionsUpdates, 65 | notifyOnlyWhenIdle: () => Boolean(memoOptions.deferUpdateUntilIdle), 66 | priority: () => memoOptions.priority || 'normal', 67 | recalculateLayoutBeforeUpdate: (...args) => 68 | memoOptions.recalculateLayoutBeforeUpdate 69 | ? memoOptions.recalculateLayoutBeforeUpdate(...args) 70 | : null, 71 | }); 72 | return () => removeViewportChangeListener(handleViewportChange); 73 | }, [ 74 | addViewportChangeListener || null, 75 | removeViewportChangeListener || null, 76 | ...deps, 77 | ]); 78 | } 79 | 80 | export const useViewport = (options: FullOptions = {}): Viewport => { 81 | const { getCurrentViewport } = useContext(ViewportContext); 82 | const [state, setViewport] = useState(getCurrentViewport()); 83 | useViewportEffect((viewport) => setViewport(viewport), options); 84 | 85 | return state; 86 | }; 87 | 88 | export function useScrollEffect( 89 | effect: (scroll: Scroll, snapshot: T) => void, 90 | deps?: DependencyList, 91 | ): void; 92 | 93 | export function useScrollEffect( 94 | effect: (scroll: Scroll, snapshot: T) => void, 95 | options: IEffectOptions, 96 | deps?: DependencyList, 97 | ): void; 98 | 99 | export function useScrollEffect( 100 | effect: (scroll: Scroll, snapshot: T) => void, 101 | second?: any, 102 | third?: any, 103 | ) { 104 | const { options, deps } = sortArgs(second, third); 105 | useViewportEffect( 106 | (viewport, snapshot: T) => effect(viewport.scroll, snapshot), 107 | { 108 | disableDimensionsUpdates: true, 109 | ...options, 110 | }, 111 | deps, 112 | ); 113 | } 114 | 115 | export const useScroll = (options: IOptions = {}): Scroll => { 116 | const { scroll } = useViewport({ 117 | disableDimensionsUpdates: true, 118 | ...options, 119 | }); 120 | 121 | return scroll; 122 | }; 123 | 124 | export function useDimensionsEffect( 125 | effect: (dimensions: Dimensions, snapshot: T) => void, 126 | deps?: DependencyList, 127 | ): void; 128 | 129 | export function useDimensionsEffect( 130 | effect: (dimensions: Dimensions, snapshot: T) => void, 131 | options: IEffectOptions, 132 | deps?: DependencyList, 133 | ): void; 134 | 135 | export function useDimensionsEffect( 136 | effect: (dimensions: Dimensions, snapshot: T) => void, 137 | second: any, 138 | third?: any, 139 | ) { 140 | const { options, deps } = sortArgs(second, third); 141 | useViewportEffect( 142 | (viewport, snapshot: T) => effect(viewport.dimensions, snapshot), 143 | { 144 | disableScrollUpdates: true, 145 | ...options, 146 | }, 147 | deps, 148 | ); 149 | } 150 | 151 | export const useDimensions = (options: IOptions = {}): Dimensions => { 152 | const { dimensions } = useViewport({ 153 | disableScrollUpdates: true, 154 | ...options, 155 | }); 156 | 157 | return dimensions; 158 | }; 159 | 160 | export function useRectEffect( 161 | effect: (rect: Rect | null) => void, 162 | ref: RefObject | MutableRefObject, 163 | deps?: DependencyList, 164 | ): void; 165 | 166 | export function useRectEffect( 167 | effect: (rect: Rect | null) => void, 168 | ref: RefObject | MutableRefObject, 169 | options: FullOptions, 170 | deps?: DependencyList, 171 | ): void; 172 | 173 | export function useRectEffect( 174 | effect: (rect: Rect | null) => void, 175 | ref: RefObject | MutableRefObject, 176 | third?: any, 177 | fourth?: any, 178 | ) { 179 | const { options, deps } = sortArgs(third, fourth); 180 | useViewportEffect( 181 | (_, snapshot) => effect(snapshot), 182 | { 183 | ...options, 184 | recalculateLayoutBeforeUpdate: () => 185 | ref.current ? ref.current.getBoundingClientRect() : null, 186 | }, 187 | [ref.current, ...deps], 188 | ); 189 | } 190 | 191 | export function useRect( 192 | ref: RefObject | MutableRefObject, 193 | deps?: DependencyList, 194 | ): Rect | null; 195 | 196 | export function useRect( 197 | ref: RefObject | MutableRefObject, 198 | options: FullOptions, 199 | deps?: DependencyList, 200 | ): Rect | null; 201 | 202 | export function useRect( 203 | ref: RefObject | MutableRefObject, 204 | second: any, 205 | third?: any, 206 | ): Rect | null { 207 | const { options, deps } = sortArgs(second, third); 208 | return useLayoutSnapshot( 209 | () => (ref.current ? ref.current.getBoundingClientRect() : null), 210 | options, 211 | [ref.current, ...deps], 212 | ); 213 | } 214 | 215 | export function useLayoutSnapshot( 216 | recalculateLayoutBeforeUpdate: (viewport: Viewport) => T, 217 | deps?: DependencyList, 218 | ): null | T; 219 | 220 | export function useLayoutSnapshot( 221 | recalculateLayoutBeforeUpdate: (viewport: Viewport) => T, 222 | options?: FullOptions, 223 | deps?: DependencyList, 224 | ): null | T; 225 | 226 | export function useLayoutSnapshot( 227 | recalculateLayoutBeforeUpdate: (viewport: Viewport) => T, 228 | second?: any, 229 | third?: any, 230 | ): null | T { 231 | const { options, deps } = sortArgs(second, third); 232 | const [state, setSnapshot] = useState(null); 233 | useViewportEffect( 234 | (_, snapshot: T) => setSnapshot(snapshot), 235 | { 236 | ...options, 237 | recalculateLayoutBeforeUpdate, 238 | }, 239 | deps, 240 | ); 241 | 242 | return state; 243 | } 244 | 245 | const useOptions = (o: ViewPortEffectOptions) => { 246 | const optionsRef = useRef>(Object.create(null)); 247 | for (const key of Object.keys(optionsRef.current)) { 248 | delete optionsRef.current[key]; 249 | } 250 | Object.assign(optionsRef.current, o); 251 | 252 | return optionsRef.current; 253 | }; 254 | 255 | const sortArgs = ( 256 | first: DependencyList | IOptions, 257 | second?: DependencyList, 258 | ) => { 259 | let options = {}; 260 | if (first && !Array.isArray(first)) { 261 | options = first; 262 | } 263 | let deps = second || []; 264 | if (first && Array.isArray(first)) { 265 | deps = first; 266 | } 267 | return { deps, options }; 268 | }; 269 | 270 | /** 271 | * Exposes the current viewport state as a mutable and readonly object. It will not trigger updates when the value on the viewport change but allows to access the current and most up to date information at any time without any negative performance impact. 272 | */ 273 | export const useMutableViewport = () => { 274 | return useContext(ViewportContext).getMutableViewportState(); 275 | }; 276 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViewportProvider } from './ViewportProvider'; 2 | export { default as connectViewport } from './ConnectViewport'; 3 | export { default as ObserveViewport } from './ObserveViewport'; 4 | export { 5 | useScroll, 6 | useScrollEffect, 7 | useDimensions, 8 | useDimensionsEffect, 9 | useViewport, 10 | useViewportEffect, 11 | useLayoutSnapshot, 12 | useRect, 13 | useRectEffect, 14 | useMutableViewport, 15 | } from './hooks'; 16 | export { requestAnimationFrame, cancelAnimationFrame } from './utils'; 17 | export type { 18 | Rect, 19 | Scroll, 20 | Dimensions, 21 | IRect, 22 | IScroll, 23 | IDimensions, 24 | } from './types'; 25 | 26 | export const VERSION = '_VERS_'; 27 | -------------------------------------------------------------------------------- /lib/modules.d.ts: -------------------------------------------------------------------------------- 1 | // @see https://gist.github.com/strothj/708afcf4f01dd04de8f49c92e88093c3 2 | interface Window { 3 | ResizeObserver: ResizeObserver; 4 | } 5 | 6 | /** 7 | * The ResizeObserver interface is used to observe changes to Element's content 8 | * rect. 9 | * 10 | * It is modeled after MutationObserver and IntersectionObserver. 11 | */ 12 | interface ResizeObserver { 13 | new (callback: ResizeObserverCallback): ResizeObserver; 14 | 15 | /** 16 | * Adds target to the list of observed elements. 17 | */ 18 | observe: (target: Element) => void; 19 | 20 | /** 21 | * Removes target from the list of observed elements. 22 | */ 23 | unobserve: (target: Element) => void; 24 | 25 | /** 26 | * Clears both the observationTargets and activeTargets lists. 27 | */ 28 | disconnect: () => void; 29 | } 30 | 31 | /** 32 | * This callback delivers ResizeObserver's notifications. It is invoked by a 33 | * broadcast active observations algorithm. 34 | */ 35 | interface ResizeObserverCallback { 36 | (entries: ResizeObserverEntry[], observer: ResizeObserver): void; 37 | } 38 | 39 | interface ResizeObserverEntry { 40 | /** 41 | * @param target The Element whose size has changed. 42 | */ 43 | new (target: Element): ResizeObserverEntry; 44 | 45 | /** 46 | * The Element whose size has changed. 47 | */ 48 | readonly target: Element; 49 | 50 | /** 51 | * Element's content rect when ResizeObserverCallback is invoked. 52 | */ 53 | readonly contentRect: DOMRectReadOnly; 54 | } 55 | 56 | interface DOMRectReadOnly { 57 | readonly x: number; 58 | readonly y: number; 59 | readonly width: number; 60 | readonly height: number; 61 | readonly top: number; 62 | readonly right: number; 63 | readonly bottom: number; 64 | readonly left: number; 65 | 66 | toJSON: () => any; 67 | } 68 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface Dimensions { 2 | width: number; 3 | height: number; 4 | clientWidth: number; 5 | clientHeight: number; 6 | outerWidth: number; 7 | outerHeight: number; 8 | documentWidth: number; 9 | documentHeight: number; 10 | } 11 | 12 | export interface Scroll { 13 | x: number; 14 | y: number; 15 | xTurn: number; 16 | yTurn: number; 17 | xDTurn: number; 18 | yDTurn: number; 19 | isScrollingUp: boolean; 20 | isScrollingDown: boolean; 21 | isScrollingLeft: boolean; 22 | isScrollingRight: boolean; 23 | } 24 | 25 | export interface Rect { 26 | top: number; 27 | right: number; 28 | bottom: number; 29 | left: number; 30 | height: number; 31 | width: number; 32 | } 33 | 34 | /** 35 | * @deprecated Better use Dimensions 36 | */ 37 | export interface IDimensions extends Dimensions {} 38 | /** 39 | * @deprecated Better use Scroll 40 | */ 41 | export interface IScroll extends Scroll {} 42 | /** 43 | * @deprecated Better use Rect 44 | */ 45 | export interface IRect extends Rect {} 46 | 47 | export interface Viewport { 48 | scroll: Scroll; 49 | dimensions: Dimensions; 50 | } 51 | 52 | export type ViewportChangeHandler = ( 53 | viewport: Viewport, 54 | layoutSnapshot: any, 55 | ) => void; 56 | 57 | export interface ViewportChangeOptions { 58 | notifyScroll: () => boolean; 59 | notifyDimensions: () => boolean; 60 | notifyOnlyWhenIdle: () => boolean; 61 | priority: () => PriorityType; 62 | recalculateLayoutBeforeUpdate?: (viewport: Viewport) => unknown; 63 | } 64 | 65 | export interface ViewportCollectorUpdateOptions { 66 | scrollDidUpdate: boolean; 67 | dimensionsDidUpdate: boolean; 68 | } 69 | 70 | export type OnUpdateType = ( 71 | props: Viewport, 72 | options: ViewportCollectorUpdateOptions, 73 | ) => void; 74 | 75 | export type PriorityType = 'highest' | 'high' | 'normal' | 'low'; 76 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Rect, Scroll, Dimensions, OnUpdateType } from './types'; 2 | 3 | export const shallowEqualScroll = (a: Scroll, b: Scroll) => { 4 | if (a === b) { 5 | return true; 6 | } 7 | return ( 8 | a.x === b.x && 9 | a.y === b.y && 10 | a.xTurn === b.xTurn && 11 | a.yTurn === b.yTurn && 12 | a.xDTurn === b.xDTurn && 13 | a.yDTurn === b.yDTurn && 14 | a.isScrollingUp === b.isScrollingUp && 15 | a.isScrollingDown === b.isScrollingDown && 16 | a.isScrollingLeft === b.isScrollingLeft && 17 | a.isScrollingRight === b.isScrollingRight 18 | ); 19 | }; 20 | 21 | export const shallowEqualRect = (a: Rect, b: Rect) => { 22 | if (a === b) { 23 | return true; 24 | } 25 | 26 | return ( 27 | a.top === b.top && 28 | a.right === b.right && 29 | a.bottom === b.bottom && 30 | a.left === b.left && 31 | a.height === b.height && 32 | a.width === b.width 33 | ); 34 | }; 35 | 36 | export const shallowEqualDimensions = (a: Dimensions, b: Dimensions) => { 37 | if (a === b) { 38 | return true; 39 | } 40 | 41 | return ( 42 | a.width === b.width && 43 | a.height === b.height && 44 | a.clientWidth === b.clientWidth && 45 | a.clientHeight === b.clientHeight && 46 | a.outerWidth === b.outerWidth && 47 | a.outerHeight === b.outerHeight && 48 | a.documentWidth === b.documentWidth && 49 | a.documentHeight === b.documentHeight 50 | ); 51 | }; 52 | 53 | // implementation based on https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md 54 | export const browserSupportsPassiveEvents = (() => { 55 | if (typeof window === 'undefined') { 56 | return false; 57 | } 58 | let supportsPassive = false; 59 | try { 60 | const opts = Object.defineProperty({}, 'passive', { 61 | get: () => { 62 | supportsPassive = true; 63 | }, 64 | }); 65 | window.addEventListener('testPassive', null as any, opts); 66 | window.removeEventListener('testPassive', null as any, opts); 67 | } catch (e) { 68 | return false; 69 | } 70 | return supportsPassive; 71 | })(); 72 | 73 | export const simpleDebounce = any>( 74 | fn: F, 75 | delay: number, 76 | ): F => { 77 | let timeout: NodeJS.Timer; 78 | return ((...args: any[]) => { 79 | clearTimeout(timeout); 80 | timeout = setTimeout(fn, delay, ...args); 81 | }) as F; 82 | }; 83 | 84 | export const debounceOnUpdate = ( 85 | fn: OnUpdateType, 86 | delay: number, 87 | ): OnUpdateType => { 88 | let timeout: NodeJS.Timer; 89 | let scrollDidUpdate = false; 90 | let dimensionsDidUpdate = false; 91 | 92 | return (viewport, options) => { 93 | clearTimeout(timeout); 94 | scrollDidUpdate = scrollDidUpdate || options.scrollDidUpdate; 95 | dimensionsDidUpdate = dimensionsDidUpdate || options.dimensionsDidUpdate; 96 | timeout = setTimeout(() => { 97 | fn(viewport, { 98 | scrollDidUpdate, 99 | dimensionsDidUpdate, 100 | }); 101 | scrollDidUpdate = false; 102 | dimensionsDidUpdate = false; 103 | }, delay); 104 | }; 105 | }; 106 | 107 | export const warnNoContextAvailable = (location: string) => { 108 | if (process.env.NODE_ENV === 'production') { 109 | return; 110 | } 111 | const fromHook = location.startsWith('use'); 112 | if (fromHook) { 113 | console.warn( 114 | `react-viewport-utils: ${location} hook is not able to connect to a . Therefore it cannot detect updates from the viewport and will not work as expected. To resolve this issue please add a as a parent of the component using the hook, e.g. directly in the ReactDOM.render call: 115 | 116 | import * as ReactDOM from 'react-dom'; 117 | import { ViewportProvider, ${location} } from 'react-viewport-utils'; 118 | 119 | const MyComponent = () => { 120 | ${location}() 121 | ... 122 | } 123 | 124 | ReactDOM.render( 125 | 126 |
127 | 128 |
129 |
, 130 | document.getElementById('root') 131 | );`, 132 | ); 133 | return; 134 | } 135 | console.warn( 136 | `react-viewport-utils: ${location} component is not able to connect to a . Therefore it cannot detect updates from the viewport and will not work as expected. To resolve this issue please add a as a parent of the component, e.g. directly in the ReactDOM.render call: 137 | 138 | import * as ReactDOM from 'react-dom'; 139 | import { ViewportProvider, ObserveViewport } from 'react-viewport-utils'; 140 | ReactDOM.render( 141 | 142 |
143 | 144 | {({ scroll, dimensions }) => ...} 145 | 146 |
147 |
, 148 | document.getElementById('root') 149 | );`, 150 | ); 151 | }; 152 | 153 | export const warnNoResizeObserver = () => { 154 | if (process.env.NODE_ENV === 'production') { 155 | return; 156 | } 157 | console.warn( 158 | 'react-viewport-utils: This browser does not support the ResizeObserver API, therefore not all possible resize events will be detected. In most of the cases this is not an issue and can be ignored. If its relevant to your application please consider adding a polyfill, e.g. https://www.npmjs.com/package/resize-observer-polyfill .', 159 | ); 160 | }; 161 | 162 | type RequestAnimationFrameType = (callback: FrameRequestCallback) => number; 163 | 164 | export const requestAnimationFrame = ((): RequestAnimationFrameType => { 165 | if (typeof window !== 'undefined') { 166 | const nativeRAF = 167 | window.requestAnimationFrame || 168 | (window).webkitRequestAnimationFrame || 169 | (window).mozRequestAnimationFrame; 170 | if (nativeRAF) { 171 | return nativeRAF.bind(window); 172 | } 173 | } 174 | return function requestAnimationFrameFallback( 175 | callback: FrameRequestCallback, 176 | ): number { 177 | return (setTimeout(callback, 1000 / 60) as unknown) as number; 178 | }; 179 | })(); 180 | 181 | export const cancelAnimationFrame = ((): ((handle: number) => void) => { 182 | if (typeof window !== 'undefined') { 183 | const nativeCAF = 184 | window.cancelAnimationFrame || 185 | (window).webkitCancelAnimationFrame || 186 | (window).webkitCancelRequestAnimationFrame; 187 | if (nativeCAF) { 188 | return nativeCAF.bind(window); 189 | } 190 | } 191 | return clearTimeout; 192 | })(); 193 | 194 | export const now = 195 | typeof performance !== 'undefined' && performance.now 196 | ? performance.now.bind(performance) 197 | : Date.now.bind(Date); 198 | export const createPerformanceMarker = () => { 199 | const start = now(); 200 | return () => now() - start; 201 | }; 202 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-viewport-utils", 3 | "version": "2.0.2", 4 | "description": "Utility components for working with the viewport in react", 5 | "main": "dist/commonjs.js", 6 | "module": "dist/esmodule.js", 7 | "types": "dist/types.d.ts", 8 | "source": "lib/index.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "start": "parcel ./examples/index.html --dist-dir='examples-dist'", 12 | "precompile": "rm -rf dist", 13 | "compile": "parcel build", 14 | "test": "jest", 15 | "fmt": "prettier --write \"lib/*.{ts,tsx}\" \"examples/*.{ts,tsx}\"", 16 | "prepublish": "npm run compile", 17 | "postversion": "git push && git push --tags" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git://github.com/garthenweb/react-viewport-utils" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/garthenweb/react-viewport-utils/issues" 25 | }, 26 | "engines": { 27 | "node": "^8.9.0 || >= 10.13.0" 28 | }, 29 | "author": "Jannick Garthen ", 30 | "keywords": [ 31 | "react", 32 | "viewport", 33 | "scroll", 34 | "dimensions", 35 | "size", 36 | "resize", 37 | "event", 38 | "observer", 39 | "window", 40 | "screen" 41 | ], 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@parcel/config-default": "^2.0.0", 45 | "@parcel/packager-ts": "^2.0.0", 46 | "@parcel/transformer-typescript-types": "^2.0.0", 47 | "@testing-library/react": "^12.1.1", 48 | "@types/jest": "^27.0.2", 49 | "@types/node": "^16.10.1", 50 | "@types/react": "^17.0.24", 51 | "@types/react-dom": "^17.0.9", 52 | "concurrently": "^6.2.2", 53 | "coveralls": "^3.1.1", 54 | "jest": "^27.2.3", 55 | "parcel": "^2.0.0", 56 | "parcel-transformer-replace": "link:./bundler/parcel-transformer-replace", 57 | "prettier": "^2.4.1", 58 | "react": "^16.12.0", 59 | "react-dom": "^16.12.0", 60 | "ts-jest": "^27.0.5", 61 | "typescript": "^4.4.3" 62 | }, 63 | "dependencies": { 64 | "memoize-one": "^5.1.1 || ^6.0.0" 65 | }, 66 | "peerDependencies": { 67 | "react": "^16.3.0 || >= 17.0.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "moduleResolution": "Node", 5 | "module": "es6", 6 | "target": "ES2015", 7 | "outDir": "./dist", 8 | "lib": ["es6", "dom"], 9 | "sourceMap": true, 10 | "jsx": "react", 11 | "forceConsistentCasingInFileNames": true, 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "alwaysStrict": true, 15 | "allowSyntheticDefaultImports": true, 16 | "types": ["node"] 17 | }, 18 | "include": [ 19 | "lib" 20 | ], 21 | "exclude": [ 22 | "dist", 23 | "examples-dist", 24 | ".cache" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "types": ["node", "jest"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "rules": { 4 | "max-classes-per-file": false, 5 | "jsx-no-lambda": false, 6 | "no-console": false, 7 | "object-literal-sort-keys": false 8 | }, 9 | "linterOptions": { 10 | "exclude": [ 11 | "node_modules/**/*.ts" 12 | ] 13 | } 14 | } 15 | --------------------------------------------------------------------------------