From 4ec125ffaf16e051e6abb2afe1b1b3dde8c7c4e2 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 22 Nov 2021 16:02:52 +0100 Subject: [PATCH] feat: add support for `fallbackInView` --- README.md | 72 +++++++++++++++++++++++++++-------- src/InView.tsx | 26 ++++++++++--- src/__tests__/InView.test.tsx | 64 +++++++++++++++++++++++++++++++ src/__tests__/hooks.test.tsx | 45 +++++++++++++++++++++- src/index.tsx | 5 ++- src/observe.ts | 34 ++++++++++++++++- src/test-utils.ts | 2 +- src/useInView.tsx | 3 ++ 8 files changed, 226 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1ffe0cfd..36689144 100644 --- a/README.md +++ b/README.md @@ -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 **``** 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 +**``** 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 `` 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 ( +
+

{`Header inside viewport ${inView}.`}

+
+ ); +}; +``` + ### Polyfill You can import the diff --git a/src/InView.tsx b/src/InView.tsx index b356a6fa..56df6198 100644 --- a/src/InView.tsx +++ b/src/InView.tsx @@ -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; diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx index 947cc767..6a180b84 100644 --- a/src/__tests__/InView.test.tsx +++ b/src/__tests__/InView.test.tsx @@ -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 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( + + Inner + , + ); + expect(cb).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ isIntersecting: true }), + ); + + render( + + Inner + , + ); + expect(cb).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ isIntersecting: false }), + ); + + expect(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(Inner); + // @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(Inner); + expect(cb).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ isIntersecting: true }), + ); + + defaultFallbackInView(false); + render(Inner); + expect(cb).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ isIntersecting: false }), + ); + + defaultFallbackInView(undefined); + expect(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(Inner); + // @ts-ignore + console.error.mockRestore(); + }).toThrowErrorMatchingInlineSnapshot( + `"IntersectionObserver is not a constructor"`, + ); +}); diff --git a/src/__tests__/hooks.test.tsx b/src/__tests__/hooks.test.tsx index a7de3b25..e952de44 100644 --- a/src/__tests__/hooks.test.tsx +++ b/src/__tests__/hooks.test.tsx @@ -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( + , + ); + screen.getByText('true'); + + rerender(); + screen.getByText('false'); + + expect(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + rerender(); + // @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(); + screen.getByText('true'); + + defaultFallbackInView(false); + rerender(); + screen.getByText('false'); + + defaultFallbackInView(undefined); + expect(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + rerender(); + // @ts-ignore + console.error.mockRestore(); + }).toThrowErrorMatchingInlineSnapshot( + `"IntersectionObserver is not a constructor"`, + ); +}); diff --git a/src/index.tsx b/src/index.tsx index 3c24bf2d..101fd1b0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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; @@ -39,7 +39,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 */ diff --git a/src/observe.ts b/src/observe.ts index 3461c6bc..4cc82bf8 100644 --- a/src/observe.ts +++ b/src/observe.ts @@ -12,6 +12,18 @@ export const ObserverMap = new Map< const RootIds: WeakMap = 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); diff --git a/src/test-utils.ts b/src/test-utils.ts index 4c82b41a..77a0813b 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -47,7 +47,7 @@ beforeEach(() => { afterEach(() => { // @ts-ignore - global.IntersectionObserver.mockClear(); + if (global.IntersectionObserver) global.IntersectionObserver.mockClear(); observers.clear(); ObserverMap.clear(); }); diff --git a/src/useInView.tsx b/src/useInView.tsx index c3c993b9..f1e63f08 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -43,6 +43,7 @@ export function useInView({ triggerOnce, skip, initialInView, + fallbackInView, }: IntersectionOptions = {}): InViewHookResponse { const unobserve = React.useRef(); const [state, setState] = React.useState({ @@ -79,6 +80,7 @@ export function useInView({ // @ts-ignore delay, }, + fallbackInView, ); } }, @@ -93,6 +95,7 @@ export function useInView({ triggerOnce, skip, trackVisibility, + fallbackInView, delay, ], );