Skip to content

Commit

Permalink
feat: add support for fallbackInView (#521)
Browse files Browse the repository at this point in the history
  • Loading branch information
thebuilder authored Dec 9, 2021
1 parent 249de1a commit aa50422
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 25 deletions.
72 changes: 57 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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();
};
Expand Down Expand Up @@ -175,6 +188,7 @@ export class InView extends React.Component<
trackVisibility,
delay,
initialInView,
fallbackInView,
...props
} = this.props;

Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Up @@ -6,7 +6,7 @@ import {
mockAllIsIntersecting,
mockIsIntersecting,
} from '../test-utils';
import { IntersectionOptions } from '../index';
import { IntersectionOptions, defaultFallbackInView } from '../index';

const HookComponent = ({
options,
Expand Down Expand Up @@ -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
Expand Up @@ -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;

Expand Down Expand Up @@ -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 */
Expand Down
34 changes: 33 additions & 1 deletion src/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

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

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

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

1 comment on commit aa50422

@vercel
Copy link

@vercel vercel bot commented on aa50422 Dec 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.