Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: thebuilder/react-intersection-observer
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v8.32.5
Choose a base ref
...
head repository: thebuilder/react-intersection-observer
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v8.33.0
Choose a head ref
  • 1 commit
  • 8 files changed
  • 1 contributor

Commits on Dec 9, 2021

  1. 1

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    aa50422 View commit details
Showing with 226 additions and 25 deletions.
  1. +57 −15 README.md
  2. +20 −6 src/InView.tsx
  3. +64 −0 src/__tests__/InView.test.tsx
  4. +44 −1 src/__tests__/hooks.test.tsx
  5. +4 −1 src/index.tsx
  6. +33 −1 src/observe.ts
  7. +1 −1 src/test-utils.ts
  8. +3 −0 src/useInView.tsx
72 changes: 57 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -52,9 +52,10 @@ const { ref, inView, entry } = useInView(options);
const [ref, inView, entry] = useInView(options);
```

The `useInView` hook makes it easy to monitor the `inView` state of your components. Call
the `useInView` hook with the (optional) [options](#options) you need. It will
return an array containing a `ref`, the `inView` status and the current
The `useInView` hook makes it easy to monitor the `inView` state of your
components. Call the `useInView` hook with the (optional) [options](#options)
you need. It will return an array containing a `ref`, the `inView` status and
the current
[`entry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
Assign the `ref` to the DOM element you want to monitor, and the hook will
report the status.
@@ -140,18 +141,20 @@ export default Component;

### Options

Provide these as the options argument in the `useInView` hook or as props on the **`<InView />`** component.

| Name | Type | Default | Required | Description |
| ---------------------- | ---------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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. |
| **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). |
| **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. |
| **trackVisibility** 🧪 | `boolean` | false | false | A boolean indicating whether this IntersectionObserver will track visibility changes on the target. |
| **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`. |
| **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. |
| **triggerOnce** | `boolean` | false | false | Only trigger the observer once. |
| **initialInView** | `boolean` | false | false | Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. |
Provide these as the options argument in the `useInView` hook or as props on the
**`<InView />`** component.

| Name | Type | Default | Required | Description |
| ---------------------- | ---------------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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. |
| **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). |
| **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. |
| **trackVisibility** 🧪 | `boolean` | false | false | A boolean indicating whether this IntersectionObserver will track visibility changes on the target. |
| **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`. |
| **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. |
| **triggerOnce** | `boolean` | false | false | Only trigger the observer once. |
| **initialInView** | `boolean` | false | false | Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. |
| **fallbackInView** | `boolean` | undefined | false | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` |

### InView Props

@@ -307,6 +310,45 @@ With
all major browsers now support Intersection Observers natively. Add the
polyfill, so it doesn't break on older versions of iOS and IE11.
### Unsupported fallback
If the client doesn't have support for the `IntersectionObserver`, then the
default behavior is to throw an error. This will crash the React application,
unless you capture it with an Error Boundary.
If you prefer, you can set a fallback `inView` value to use if the
`IntersectionObserver` doesn't exist. This will make
`react-intersection-observer` fail gracefully, but you must ensure your
application can correctly handle all your observers firing either `true` or
`false` at the same time.
You can set the fallback globally:
```js
import { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(true); // or 'false'
```
You can also define the fallback locally on `useInView` or `<InView>` as an
option. This will override the global fallback value.
```jsx
import React from 'react';
import { useInView } from 'react-intersection-observer';

const Component = () => {
const { ref, inView, entry } = useInView({
fallbackInView: true,
});

return (
<div ref={ref}>
<h2>{`Header inside viewport ${inView}.`}</h2>
</div>
);
};
```
### Polyfill
You can import the
26 changes: 20 additions & 6 deletions src/InView.tsx
Original file line number Diff line number Diff line change
@@ -106,17 +106,29 @@ export class InView extends React.Component<

observeNode() {
if (!this.node || this.props.skip) return;
const { threshold, root, rootMargin, trackVisibility, delay } = this.props;

this._unobserveCb = observe(this.node, this.handleChange, {
const {
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
});
fallbackInView,
} = this.props;

this._unobserveCb = observe(
this.node,
this.handleChange,
{
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
},
fallbackInView,
);
}

unobserve() {
@@ -136,6 +148,7 @@ export class InView extends React.Component<
this.setState({ inView: !!this.props.initialInView, entry: undefined });
}
}

this.node = node ? node : null;
this.observeNode();
};
@@ -175,6 +188,7 @@ export class InView extends React.Component<
trackVisibility,
delay,
initialInView,
fallbackInView,
...props
} = this.props;

64 changes: 64 additions & 0 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import React from 'react';
import { screen, fireEvent, render } from '@testing-library/react';
import { intersectionMockInstance, mockAllIsIntersecting } from '../test-utils';
import { InView } from '../InView';
import { defaultFallbackInView } from '../observe';

it('Should render <InView /> intersecting', () => {
const callback = jest.fn();
@@ -155,3 +156,66 @@ it('plain children should not catch bubbling onChange event', () => {
fireEvent.change(input, { target: { value: 'changed value' } });
expect(onChange).not.toHaveBeenCalled();
});

it('should render with fallback', () => {
const cb = jest.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
render(
<InView fallbackInView={true} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

render(
<InView fallbackInView={false} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

expect(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`"IntersectionObserver is not a constructor"`,
);
});

it('should render with global fallback', () => {
const cb = jest.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

defaultFallbackInView(false);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

defaultFallbackInView(undefined);
expect(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`"IntersectionObserver is not a constructor"`,
);
});
45 changes: 44 additions & 1 deletion src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ import {
mockAllIsIntersecting,
mockIsIntersecting,
} from '../test-utils';
import { IntersectionOptions } from '../index';
import { IntersectionOptions, defaultFallbackInView } from '../index';

const HookComponent = ({
options,
@@ -318,3 +318,46 @@ test('should set intersection ratio as the largest threshold smaller than trigge
mockIsIntersecting(wrapper, 0.5);
expect(screen.getByText(/intersectionRatio: 0.5/g)).toBeInTheDocument();
});

test('should handle fallback if unsupported', () => {
// @ts-ignore
window.IntersectionObserver = undefined;
const { rerender } = render(
<HookComponent options={{ fallbackInView: true }} />,
);
screen.getByText('true');

rerender(<HookComponent options={{ fallbackInView: false }} />);
screen.getByText('false');

expect(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
rerender(<HookComponent options={{ fallbackInView: undefined }} />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`"IntersectionObserver is not a constructor"`,
);
});

test('should handle defaultFallbackInView if unsupported', () => {
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
const { rerender } = render(<HookComponent key="true" />);
screen.getByText('true');

defaultFallbackInView(false);
rerender(<HookComponent key="false" />);
screen.getByText('false');

defaultFallbackInView(undefined);
expect(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
rerender(<HookComponent key="undefined" />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`"IntersectionObserver is not a constructor"`,
);
});
5 changes: 4 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
import { InView } from './InView';
export { InView } from './InView';
export { useInView } from './useInView';
export { observe } from './observe';
export { observe, defaultFallbackInView } from './observe';

export default InView;

@@ -30,7 +30,10 @@ export interface IntersectionOptions extends IntersectionObserverInit {
triggerOnce?: boolean;
/** Skip assigning the observer to the `ref` */
skip?: boolean;
/** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */
initialInView?: boolean;
/** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */
fallbackInView?: boolean;
/** IntersectionObserver v2 - Track the actual visibility of the element */
trackVisibility?: boolean;
/** IntersectionObserver v2 - Set a minimum delay between notifications */
34 changes: 33 additions & 1 deletion src/observe.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,18 @@ const observerMap = new Map<
const RootIds: WeakMap<Element | Document, string> = new WeakMap();
let rootId = 0;

let unsupportedValue: boolean | undefined = undefined;

/**
* What should be the default behavior if the IntersectionObserver is unsupported?
* Ideally the polyfill has been loaded, you can have the following happen:
* - `undefined`: Throw an error
* - `true` or `false`: Set the `inView` value to this regardless of intersection state
* **/
export function defaultFallbackInView(inView: boolean | undefined) {
unsupportedValue = inView;
}

/**
* Generate a unique ID for the root element
* @param root
@@ -95,14 +107,34 @@ function createObserver(options: IntersectionObserverInit) {
* @param element - DOM Element to observe
* @param callback - Callback function to trigger when intersection status changes
* @param options - Intersection Observer options
* @param fallbackInView - Fallback inView value.
* @return Function - Cleanup function that should be triggered to unregister the observer
*/
export function observe(
element: Element,
callback: ObserverInstanceCallback,
options: IntersectionObserverInit = {},
fallbackInView = unsupportedValue,
) {
if (!element) return () => {};
if (
typeof window.IntersectionObserver === 'undefined' &&
fallbackInView !== undefined
) {
const bounds = element.getBoundingClientRect();
callback(fallbackInView, {
isIntersecting: fallbackInView,
target: element,
intersectionRatio:
typeof options.threshold === 'number' ? options.threshold : 0,
time: 0,
boundingClientRect: bounds,
intersectionRect: bounds,
rootBounds: bounds,
});
return () => {
// Nothing to cleanup
};
}
// An observer with the same options can be reused, so lets use this fact
const { id, observer, elements } = createObserver(options);

2 changes: 1 addition & 1 deletion src/test-utils.ts
Original file line number Diff line number Diff line change
@@ -46,7 +46,7 @@ beforeEach(() => {

afterEach(() => {
// @ts-ignore
global.IntersectionObserver.mockClear();
if (global.IntersectionObserver) global.IntersectionObserver.mockClear();
observers.clear();
});

3 changes: 3 additions & 0 deletions src/useInView.tsx
Original file line number Diff line number Diff line change
@@ -43,6 +43,7 @@ export function useInView({
triggerOnce,
skip,
initialInView,
fallbackInView,
}: IntersectionOptions = {}): InViewHookResponse {
const unobserve = React.useRef<Function>();
const [state, setState] = React.useState<State>({
@@ -79,6 +80,7 @@ export function useInView({
// @ts-ignore
delay,
},
fallbackInView,
);
}
},
@@ -93,6 +95,7 @@ export function useInView({
triggerOnce,
skip,
trackVisibility,
fallbackInView,
delay,
],
);