Skip to content

Commit 9d53139

Browse files
committed
feat: add initial support for IntersectionObserver v2
BREAKING CHANGE
1 parent f4ec56f commit 9d53139

File tree

11 files changed

+1299
-1017
lines changed

11 files changed

+1299
-1017
lines changed

README.md

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ to tell you when an element enters or leaves the viewport. Contains both a
2626
where possible
2727
- ⚙️ **Matches native API** - Intuitive to use
2828
- 🌳 **Tree-shakeable** - Only include the parts you use
29-
- 💥 **Tiny bundle** [~1.7 kB gzipped][bundlephobia-url]
29+
- 💥 **Tiny bundle** [~1.5 kB gzipped][bundlephobia-url]
3030

3131
## Installation
3232

@@ -42,25 +42,24 @@ or NPM:
4242
npm install react-intersection-observer --save
4343
```
4444

45-
> ⚠️ You also want to add the
46-
> [intersection-observer](https://www.npmjs.com/package/react-intersection-observer)
47-
> polyfill for full browser support. Check out adding the [polyfill](#polyfill)
48-
> for details about how you can include it.
49-
5045
## Usage
5146

5247
### Hooks 🎣
5348

5449
#### `useInView`
5550

5651
```js
52+
// Use object destructing, so you don't need to remember the exact order
53+
const { ref, inView, entry } = useInView(options);
54+
55+
// Or array destructing, making it easy to customize the field names
5756
const [ref, inView, entry] = useInView(options);
5857
```
5958

6059
React Hooks make it easy to monitor the `inView` state of your components. Call
6160
the `useInView` hook with the (optional) [options](#options) you need. It will
6261
return an array containing a `ref`, the `inView` status and the current
63-
[`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
62+
[`entry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
6463
Assign the `ref` to the DOM element you want to monitor, and the hook will
6564
report the status.
6665

@@ -69,7 +68,7 @@ import React from 'react';
6968
import { useInView } from 'react-intersection-observer';
7069

7170
const Component = () => {
72-
const [ref, inView, entry] = useInView({
71+
const { ref, inView, entry } = useInView({
7372
/* Optional options */
7473
threshold: 0,
7574
});
@@ -141,21 +140,23 @@ export default Component;
141140

142141
### Options
143142

144-
Provide these as props on the **`<InView />`** component and as the options
143+
Provide these as props on the **`<InView />`** component or as the options
145144
argument for the hooks.
146145

147-
| Name | Type | Default | Required | Description |
148-
| --------------- | ------------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149-
| **root** | Element | document | false | The IntersectionObserver interface's read-only root property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the root is null, then the bounds of the actual document viewport are used. |
150-
| **rootMargin** | string | '0px' | false | Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). |
151-
| **threshold** | number \| number[] | 0 | false | Number between 0 and 1 indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. |
152-
| **skip** | boolean | false | false | Skip creating the IntersectionObserver. You can use this to enable and disable the observer as needed. If `skip` is set while `inView`, the current state will still be kept. |
153-
| **triggerOnce** | boolean | false | false | Only trigger this method once. |
146+
| Name | Type | Default | Required | Description |
147+
| ---------------------- | ------------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
148+
| **root** | Element | document | false | The IntersectionObserver interface's read-only root property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the root is null, then the bounds of the actual document viewport are used. |
149+
| **rootMargin** | string | '0px' | false | Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). |
150+
| **threshold** | number \| number[] | 0 | false | Number between 0 and 1 indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. |
151+
| **trackVisibility** 🧪 | boolean | false | false | A boolean indicating whether this IntersectionObserver will track changes in a target’s visibility. |
152+
| **delay** 🧪 | number | undefined | false | A number indicating the minimum delay in milliseconds between notifications from this observer for a given target. This must be set to at least `100` if `trackVisibility` is `true`. |
153+
| **skip** | boolean | false | false | Skip creating the IntersectionObserver. You can use this to enable and disable the observer as needed. If `skip` is set while `inView`, the current state will still be kept. |
154+
| **triggerOnce** | boolean | false | false | Only trigger the observer once. |
154155

155156
> ⚠️ When passing an array to `threshold`, store the array in a constant to
156157
> avoid the component re-rendering too often. For example:
157158
158-
```js
159+
```jsx
159160
const THRESHOLD = [0.25, 0.5, 0.75]; // Store multiple thresholds in a constant
160161
const MyComponent = () => {
161162
const [ref, inView, entry] = useInView({ threshold: THRESHOLD });
@@ -177,6 +178,29 @@ The **`<InView />`** component also accepts the following props:
177178
| **children** | `({ref, inView, entry}) => React.ReactNode`, `ReactNode` | | true | Children expects a function that receives an object containing the `inView` boolean and a `ref` that should be assigned to the element root. Alternatively pass a plain child, to have the `<InView />` deal with the wrapping element. You will also get the `IntersectionObserverEntry` as `entry, giving you more details. |
178179
| **onChange** | `(inView, entry) => void` | | false | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. |
179180

181+
### IntersectionObserver v2 🧪
182+
183+
The new
184+
[v2 implementation of IntersectionObserver](https://developers.google.com/web/updates/2019/02/intersectionobserver-v2)
185+
extends the original API, so you can track if the element is covered by another
186+
element or has filters applied to it. Useful for blocking clickjacking attempts
187+
or tracking ad exposure.
188+
189+
To use it, you'll need to add the new `trackVisibility` and `delay` options.
190+
When you get the `entry` back, you can then monitor if `isVisible` is `true`.
191+
192+
```jsx
193+
const TrackVisible = () => {
194+
const { ref, entry } = useInView({ trackVisibility: true, delay: 100 });
195+
return <div ref={ref}>{entry?.isVisible}</div>;
196+
};
197+
```
198+
199+
This is still a very new addition, so check
200+
[caniuse](https://caniuse.com/#feat=intersectionobserver-v2) for current browser
201+
support. If `trackVisibility` has been set, and the current browser doesn't
202+
support it, a fallback has been added to always report `isVisible` as `true`.
203+
180204
## Recipes
181205
182206
The `IntersectionObserver` itself is just a simple but powerful tool. Here's a
@@ -193,7 +217,7 @@ few ideas for how you can use it.
193217
194218
You can wrap multiple `ref` assignments in a single `useCallback`:
195219
196-
```js
220+
```jsx
197221
import React, { useRef } from 'react';
198222
import { useInView } from 'react-intersection-observer';
199223

package.json

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -131,51 +131,52 @@
131131
"react": "^15.0.0 || ^16.0.0 || ^17.0.0|| ^17.0.0"
132132
},
133133
"devDependencies": {
134-
"@babel/cli": "^7.8.4",
135-
"@babel/core": "^7.9.0",
134+
"@babel/cli": "^7.11.5",
135+
"@babel/core": "^7.11.5",
136136
"@babel/plugin-proposal-class-properties": "^7.8.3",
137-
"@babel/plugin-transform-runtime": "^7.9.0",
138-
"@babel/preset-env": "^7.9.5",
137+
"@babel/plugin-transform-runtime": "^7.11.5",
138+
"@babel/preset-env": "^7.11.5",
139139
"@babel/preset-flow": "^7.9.0",
140140
"@babel/preset-react": "^7.9.4",
141141
"@babel/preset-typescript": "^7.9.0",
142-
"@emotion/react": "11.0.0-next.12",
143-
"@storybook/addon-actions": "^6.0.13",
144-
"@storybook/addon-viewport": "^6.0.13",
145-
"@storybook/react": "^6.0.13",
146-
"@storybook/theming": "^6.0.13",
142+
"@emotion/react": "11.0.0-next.15",
143+
"@storybook/addon-actions": "^6.0.21",
144+
"@storybook/addon-viewport": "^6.0.21",
145+
"@storybook/react": "^6.0.21",
146+
"@storybook/theming": "^6.0.21",
147147
"@testing-library/jest-dom": "^5.5.0",
148148
"@testing-library/react": "^10.0.2",
149-
"@types/jest": "^26.0.8",
150-
"@types/react": "^16.9.34",
149+
"@types/jest": "^26.0.12",
150+
"@types/react": "^16.9.49",
151151
"@types/react-dom": "^16.9.6",
152-
"@typescript-eslint/eslint-plugin": "^2.27.0",
153-
"@typescript-eslint/parser": "^2.27.0",
152+
"@typescript-eslint/eslint-plugin": "^4.0.1",
153+
"@typescript-eslint/parser": "^4.0.1",
154154
"babel-eslint": "^10.1.0",
155-
"babel-jest": "^25.3.0",
155+
"babel-jest": "^26.3.0",
156156
"babel-loader": "^8.1.0",
157157
"babel-plugin-dev-expression": "^0.2.2",
158158
"concurrently": "^5.1.0",
159159
"coveralls": "^3.0.11",
160-
"eslint": "^6.8.0",
160+
"eslint": "^7.8.0",
161161
"eslint-config-react-app": "^5.2.1",
162-
"eslint-plugin-flowtype": "^4.7.0",
162+
"eslint-plugin-flowtype": "^5.2.0",
163163
"eslint-plugin-import": "^2.20.2",
164164
"eslint-plugin-jsx-a11y": "^6.2.3",
165165
"eslint-plugin-react": "^7.19.0",
166-
"eslint-plugin-react-hooks": "^3.0.0",
167-
"framer-motion": "^1.10.3",
166+
"eslint-plugin-react-hooks": "^4.1.0",
167+
"framer-motion": "^2.6.5",
168168
"husky": "^4.2.5",
169-
"intersection-observer": "^0.7.0",
170-
"jest": "^25.3.0",
169+
"intersection-observer": "^0.11.0",
170+
"jest": "^26.4.2",
171171
"lint-staged": "^10.1.3",
172172
"microbundle": "^0.12.3",
173173
"npm-run-all": "^4.1.5",
174174
"prettier": "^2.0.4",
175+
"prettier-plugin-pkg": "^0.8.0",
175176
"react": "^17.0.0-rc.1",
176177
"react-dom": "^17.0.0-rc.1",
177178
"react-test-renderer": "^17.0.0-rc.1",
178-
"typescript": "^3.8.3"
179+
"typescript": "^4.0.2"
179180
},
180181
"resolutions": {
181182
"react": "17.0.0-rc.1",

src/InView.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { IntersectionObserverProps, PlainChildrenProps } from './index';
3-
import { newObserve } from './observers';
3+
import { observe } from './observers';
44

55
type State = {
66
inView: boolean;
@@ -37,7 +37,9 @@ export class InView extends React.Component<
3737
prevProps.rootMargin !== this.props.rootMargin ||
3838
prevProps.root !== this.props.root ||
3939
prevProps.threshold !== this.props.threshold ||
40-
prevProps.skip !== this.props.skip
40+
prevProps.skip !== this.props.skip ||
41+
prevProps.trackVisibility !== this.props.trackVisibility ||
42+
prevProps.delay !== this.props.delay
4143
) {
4244
this.unobserve();
4345
this.observeNode();
@@ -63,11 +65,16 @@ export class InView extends React.Component<
6365

6466
observeNode() {
6567
if (!this.node || this.props.skip) return;
66-
const { threshold, root, rootMargin } = this.props;
67-
this._unobserveCb = newObserve(this.node, this.handleChange, {
68+
const { threshold, root, rootMargin, trackVisibility, delay } = this.props;
69+
70+
this._unobserveCb = observe(this.node, this.handleChange, {
6871
threshold,
6972
root,
7073
rootMargin,
74+
// @ts-ignore
75+
trackVisibility,
76+
// @ts-ignore
77+
delay,
7178
});
7279
}
7380

@@ -118,6 +125,8 @@ export class InView extends React.Component<
118125
rootMargin,
119126
onChange,
120127
skip,
128+
trackVisibility,
129+
delay,
121130
...props
122131
} = this.props;
123132

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,28 @@ it('should render plain children', () => {
3131

3232
it('should render with tag', () => {
3333
const { container } = render(<InView tag="span">inner</InView>);
34-
const tagName = container.firstChild.tagName.toLowerCase();
34+
const tagName = container.children[0].tagName.toLowerCase();
3535
expect(tagName).toBe('span');
3636
});
3737

3838
it('should render with className', () => {
3939
const { container } = render(<InView className="inner-class">inner</InView>);
40-
expect(container.firstChild).toHaveClass('inner-class');
40+
expect(container.children[0]).toHaveClass('inner-class');
4141
});
4242

4343
it('Should respect skip', () => {
4444
const cb = jest.fn();
45-
render(<InView skip onChange={cb}></InView>);
46-
mockAllIsIntersecting();
45+
render(
46+
<InView skip onChange={cb}>
47+
inner
48+
</InView>,
49+
);
50+
mockAllIsIntersecting(true);
4751

4852
expect(cb).not.toHaveBeenCalled();
4953
});
5054
it('Should unobserve old node', () => {
51-
const { rerender, container } = render(
55+
const { rerender } = render(
5256
<InView>
5357
{({ inView, ref }) => (
5458
<div key="1" ref={ref}>
@@ -77,7 +81,7 @@ it('Should ensure node exists before observing and unobserving', () => {
7781
it('Should recreate observer when threshold change', () => {
7882
const { container, rerender } = render(<InView>Inner</InView>);
7983
mockAllIsIntersecting(true);
80-
const instance = intersectionMockInstance(container.firstChild);
84+
const instance = intersectionMockInstance(container.children[0]);
8185
jest.spyOn(instance, 'unobserve');
8286

8387
rerender(<InView threshold={0.5}>Inner</InView>);
@@ -87,27 +91,28 @@ it('Should recreate observer when threshold change', () => {
8791
it('Should recreate observer when root change', () => {
8892
const { container, rerender } = render(<InView>Inner</InView>);
8993
mockAllIsIntersecting(true);
90-
const instance = intersectionMockInstance(container.firstChild);
94+
const instance = intersectionMockInstance(container.children[0]);
9195
jest.spyOn(instance, 'unobserve');
9296

93-
rerender(<InView root={{}}>Inner</InView>);
97+
const root = document.createElement('div');
98+
rerender(<InView root={root}>Inner</InView>);
9499
expect(instance.unobserve).toHaveBeenCalled();
95100
});
96101

97102
it('Should recreate observer when rootMargin change', () => {
98103
const { container, rerender } = render(<InView>Inner</InView>);
99104
mockAllIsIntersecting(true);
100-
const instance = intersectionMockInstance(container.firstChild);
105+
const instance = intersectionMockInstance(container.children[0]);
101106
jest.spyOn(instance, 'unobserve');
102107

103108
rerender(<InView rootMargin="10px">Inner</InView>);
104109
expect(instance.unobserve).toHaveBeenCalled();
105110
});
106111

107112
it('Should unobserve when triggerOnce comes into view', () => {
108-
const { container, rerender } = render(<InView triggerOnce>Inner</InView>);
113+
const { container } = render(<InView triggerOnce>Inner</InView>);
109114
mockAllIsIntersecting(false);
110-
const instance = intersectionMockInstance(container.firstChild);
115+
const instance = intersectionMockInstance(container.children[0]);
111116
jest.spyOn(instance, 'unobserve');
112117
mockAllIsIntersecting(true);
113118

@@ -116,7 +121,7 @@ it('Should unobserve when triggerOnce comes into view', () => {
116121

117122
it('Should unobserve when unmounted', () => {
118123
const { container, unmount } = render(<InView triggerOnce>Inner</InView>);
119-
const instance = intersectionMockInstance(container.firstChild);
124+
const instance = intersectionMockInstance(container.children[0]);
120125
jest.spyOn(instance, 'unobserve');
121126

122127
unmount();

0 commit comments

Comments
 (0)