Skip to content

Commit aa50422

Browse files
authoredDec 9, 2021
feat: add support for fallbackInView (#521)
1 parent 249de1a commit aa50422

8 files changed

+226
-25
lines changed
 

‎README.md

+57-15
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ const { ref, inView, entry } = useInView(options);
5252
const [ref, inView, entry] = useInView(options);
5353
```
5454

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

141142
### Options
142143

143-
Provide these as the options argument in the `useInView` hook or as props on the **`<InView />`** component.
144-
145-
| Name | Type | Default | Required | Description |
146-
| ---------------------- | ---------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
147-
| **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. |
148-
| **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). |
149-
| **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. |
150-
| **trackVisibility** 🧪 | `boolean` | false | false | A boolean indicating whether this IntersectionObserver will track visibility changes on the target. |
151-
| **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`. |
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 the observer once. |
154-
| **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. |
144+
Provide these as the options argument in the `useInView` hook or as props on the
145+
**`<InView />`** component.
146+
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+
| **trackVisibility** 🧪 | `boolean` | false | false | A boolean indicating whether this IntersectionObserver will track visibility changes on the target. |
153+
| **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`. |
154+
| **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. |
155+
| **triggerOnce** | `boolean` | false | false | Only trigger the observer once. |
156+
| **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. |
157+
| **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()` |
155158

156159
### InView Props
157160

@@ -307,6 +310,45 @@ With
307310
all major browsers now support Intersection Observers natively. Add the
308311
polyfill, so it doesn't break on older versions of iOS and IE11.
309312
313+
### Unsupported fallback
314+
315+
If the client doesn't have support for the `IntersectionObserver`, then the
316+
default behavior is to throw an error. This will crash the React application,
317+
unless you capture it with an Error Boundary.
318+
319+
If you prefer, you can set a fallback `inView` value to use if the
320+
`IntersectionObserver` doesn't exist. This will make
321+
`react-intersection-observer` fail gracefully, but you must ensure your
322+
application can correctly handle all your observers firing either `true` or
323+
`false` at the same time.
324+
325+
You can set the fallback globally:
326+
327+
```js
328+
import { defaultFallbackInView } from 'react-intersection-observer';
329+
defaultFallbackInView(true); // or 'false'
330+
```
331+
332+
You can also define the fallback locally on `useInView` or `<InView>` as an
333+
option. This will override the global fallback value.
334+
335+
```jsx
336+
import React from 'react';
337+
import { useInView } from 'react-intersection-observer';
338+
339+
const Component = () => {
340+
const { ref, inView, entry } = useInView({
341+
fallbackInView: true,
342+
});
343+
344+
return (
345+
<div ref={ref}>
346+
<h2>{`Header inside viewport ${inView}.`}</h2>
347+
</div>
348+
);
349+
};
350+
```
351+
310352
### Polyfill
311353
312354
You can import the

‎src/InView.tsx

+20-6
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,29 @@ export class InView extends React.Component<
106106

107107
observeNode() {
108108
if (!this.node || this.props.skip) return;
109-
const { threshold, root, rootMargin, trackVisibility, delay } = this.props;
110-
111-
this._unobserveCb = observe(this.node, this.handleChange, {
109+
const {
112110
threshold,
113111
root,
114112
rootMargin,
115-
// @ts-ignore
116113
trackVisibility,
117-
// @ts-ignore
118114
delay,
119-
});
115+
fallbackInView,
116+
} = this.props;
117+
118+
this._unobserveCb = observe(
119+
this.node,
120+
this.handleChange,
121+
{
122+
threshold,
123+
root,
124+
rootMargin,
125+
// @ts-ignore
126+
trackVisibility,
127+
// @ts-ignore
128+
delay,
129+
},
130+
fallbackInView,
131+
);
120132
}
121133

122134
unobserve() {
@@ -136,6 +148,7 @@ export class InView extends React.Component<
136148
this.setState({ inView: !!this.props.initialInView, entry: undefined });
137149
}
138150
}
151+
139152
this.node = node ? node : null;
140153
this.observeNode();
141154
};
@@ -175,6 +188,7 @@ export class InView extends React.Component<
175188
trackVisibility,
176189
delay,
177190
initialInView,
191+
fallbackInView,
178192
...props
179193
} = this.props;
180194

‎src/__tests__/InView.test.tsx

+64
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { screen, fireEvent, render } from '@testing-library/react';
33
import { intersectionMockInstance, mockAllIsIntersecting } from '../test-utils';
44
import { InView } from '../InView';
5+
import { defaultFallbackInView } from '../observe';
56

67
it('Should render <InView /> intersecting', () => {
78
const callback = jest.fn();
@@ -155,3 +156,66 @@ it('plain children should not catch bubbling onChange event', () => {
155156
fireEvent.change(input, { target: { value: 'changed value' } });
156157
expect(onChange).not.toHaveBeenCalled();
157158
});
159+
160+
it('should render with fallback', () => {
161+
const cb = jest.fn();
162+
// @ts-ignore
163+
window.IntersectionObserver = undefined;
164+
render(
165+
<InView fallbackInView={true} onChange={cb}>
166+
Inner
167+
</InView>,
168+
);
169+
expect(cb).toHaveBeenLastCalledWith(
170+
true,
171+
expect.objectContaining({ isIntersecting: true }),
172+
);
173+
174+
render(
175+
<InView fallbackInView={false} onChange={cb}>
176+
Inner
177+
</InView>,
178+
);
179+
expect(cb).toHaveBeenLastCalledWith(
180+
false,
181+
expect.objectContaining({ isIntersecting: false }),
182+
);
183+
184+
expect(() => {
185+
jest.spyOn(console, 'error').mockImplementation(() => {});
186+
render(<InView onChange={cb}>Inner</InView>);
187+
// @ts-ignore
188+
console.error.mockRestore();
189+
}).toThrowErrorMatchingInlineSnapshot(
190+
`"IntersectionObserver is not a constructor"`,
191+
);
192+
});
193+
194+
it('should render with global fallback', () => {
195+
const cb = jest.fn();
196+
// @ts-ignore
197+
window.IntersectionObserver = undefined;
198+
defaultFallbackInView(true);
199+
render(<InView onChange={cb}>Inner</InView>);
200+
expect(cb).toHaveBeenLastCalledWith(
201+
true,
202+
expect.objectContaining({ isIntersecting: true }),
203+
);
204+
205+
defaultFallbackInView(false);
206+
render(<InView onChange={cb}>Inner</InView>);
207+
expect(cb).toHaveBeenLastCalledWith(
208+
false,
209+
expect.objectContaining({ isIntersecting: false }),
210+
);
211+
212+
defaultFallbackInView(undefined);
213+
expect(() => {
214+
jest.spyOn(console, 'error').mockImplementation(() => {});
215+
render(<InView onChange={cb}>Inner</InView>);
216+
// @ts-ignore
217+
console.error.mockRestore();
218+
}).toThrowErrorMatchingInlineSnapshot(
219+
`"IntersectionObserver is not a constructor"`,
220+
);
221+
});

‎src/__tests__/hooks.test.tsx

+44-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
mockAllIsIntersecting,
77
mockIsIntersecting,
88
} from '../test-utils';
9-
import { IntersectionOptions } from '../index';
9+
import { IntersectionOptions, defaultFallbackInView } from '../index';
1010

1111
const HookComponent = ({
1212
options,
@@ -318,3 +318,46 @@ test('should set intersection ratio as the largest threshold smaller than trigge
318318
mockIsIntersecting(wrapper, 0.5);
319319
expect(screen.getByText(/intersectionRatio: 0.5/g)).toBeInTheDocument();
320320
});
321+
322+
test('should handle fallback if unsupported', () => {
323+
// @ts-ignore
324+
window.IntersectionObserver = undefined;
325+
const { rerender } = render(
326+
<HookComponent options={{ fallbackInView: true }} />,
327+
);
328+
screen.getByText('true');
329+
330+
rerender(<HookComponent options={{ fallbackInView: false }} />);
331+
screen.getByText('false');
332+
333+
expect(() => {
334+
jest.spyOn(console, 'error').mockImplementation(() => {});
335+
rerender(<HookComponent options={{ fallbackInView: undefined }} />);
336+
// @ts-ignore
337+
console.error.mockRestore();
338+
}).toThrowErrorMatchingInlineSnapshot(
339+
`"IntersectionObserver is not a constructor"`,
340+
);
341+
});
342+
343+
test('should handle defaultFallbackInView if unsupported', () => {
344+
// @ts-ignore
345+
window.IntersectionObserver = undefined;
346+
defaultFallbackInView(true);
347+
const { rerender } = render(<HookComponent key="true" />);
348+
screen.getByText('true');
349+
350+
defaultFallbackInView(false);
351+
rerender(<HookComponent key="false" />);
352+
screen.getByText('false');
353+
354+
defaultFallbackInView(undefined);
355+
expect(() => {
356+
jest.spyOn(console, 'error').mockImplementation(() => {});
357+
rerender(<HookComponent key="undefined" />);
358+
// @ts-ignore
359+
console.error.mockRestore();
360+
}).toThrowErrorMatchingInlineSnapshot(
361+
`"IntersectionObserver is not a constructor"`,
362+
);
363+
});

‎src/index.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import { InView } from './InView';
33
export { InView } from './InView';
44
export { useInView } from './useInView';
5-
export { observe } from './observe';
5+
export { observe, defaultFallbackInView } from './observe';
66

77
export default InView;
88

@@ -30,7 +30,10 @@ export interface IntersectionOptions extends IntersectionObserverInit {
3030
triggerOnce?: boolean;
3131
/** Skip assigning the observer to the `ref` */
3232
skip?: boolean;
33+
/** 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. */
3334
initialInView?: boolean;
35+
/** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */
36+
fallbackInView?: boolean;
3437
/** IntersectionObserver v2 - Track the actual visibility of the element */
3538
trackVisibility?: boolean;
3639
/** IntersectionObserver v2 - Set a minimum delay between notifications */

‎src/observe.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ const observerMap = new Map<
1212
const RootIds: WeakMap<Element | Document, string> = new WeakMap();
1313
let rootId = 0;
1414

15+
let unsupportedValue: boolean | undefined = undefined;
16+
17+
/**
18+
* What should be the default behavior if the IntersectionObserver is unsupported?
19+
* Ideally the polyfill has been loaded, you can have the following happen:
20+
* - `undefined`: Throw an error
21+
* - `true` or `false`: Set the `inView` value to this regardless of intersection state
22+
* **/
23+
export function defaultFallbackInView(inView: boolean | undefined) {
24+
unsupportedValue = inView;
25+
}
26+
1527
/**
1628
* Generate a unique ID for the root element
1729
* @param root
@@ -95,14 +107,34 @@ function createObserver(options: IntersectionObserverInit) {
95107
* @param element - DOM Element to observe
96108
* @param callback - Callback function to trigger when intersection status changes
97109
* @param options - Intersection Observer options
110+
* @param fallbackInView - Fallback inView value.
98111
* @return Function - Cleanup function that should be triggered to unregister the observer
99112
*/
100113
export function observe(
101114
element: Element,
102115
callback: ObserverInstanceCallback,
103116
options: IntersectionObserverInit = {},
117+
fallbackInView = unsupportedValue,
104118
) {
105-
if (!element) return () => {};
119+
if (
120+
typeof window.IntersectionObserver === 'undefined' &&
121+
fallbackInView !== undefined
122+
) {
123+
const bounds = element.getBoundingClientRect();
124+
callback(fallbackInView, {
125+
isIntersecting: fallbackInView,
126+
target: element,
127+
intersectionRatio:
128+
typeof options.threshold === 'number' ? options.threshold : 0,
129+
time: 0,
130+
boundingClientRect: bounds,
131+
intersectionRect: bounds,
132+
rootBounds: bounds,
133+
});
134+
return () => {
135+
// Nothing to cleanup
136+
};
137+
}
106138
// An observer with the same options can be reused, so lets use this fact
107139
const { id, observer, elements } = createObserver(options);
108140

‎src/test-utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ beforeEach(() => {
4646

4747
afterEach(() => {
4848
// @ts-ignore
49-
global.IntersectionObserver.mockClear();
49+
if (global.IntersectionObserver) global.IntersectionObserver.mockClear();
5050
observers.clear();
5151
});
5252

‎src/useInView.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function useInView({
4343
triggerOnce,
4444
skip,
4545
initialInView,
46+
fallbackInView,
4647
}: IntersectionOptions = {}): InViewHookResponse {
4748
const unobserve = React.useRef<Function>();
4849
const [state, setState] = React.useState<State>({
@@ -79,6 +80,7 @@ export function useInView({
7980
// @ts-ignore
8081
delay,
8182
},
83+
fallbackInView,
8284
);
8385
}
8486
},
@@ -93,6 +95,7 @@ export function useInView({
9395
triggerOnce,
9496
skip,
9597
trackVisibility,
98+
fallbackInView,
9699
delay,
97100
],
98101
);

1 commit comments

Comments
 (1)
Please sign in to comment.