diff --git a/README.md b/README.md index 75814a87..24ff9580 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ argument for the hooks. | **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 you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. | > ⚠️ When passing an array to `threshold`, store the array in a constant to > avoid the component re-rendering too often. For example: diff --git a/src/InView.tsx b/src/InView.tsx index 2f356bb1..f1a1b7f2 100644 --- a/src/InView.tsx +++ b/src/InView.tsx @@ -24,14 +24,18 @@ export class InView extends React.Component< static defaultProps = { threshold: 0, triggerOnce: false, + initialInView: false, }; - state: State = { - inView: false, - entry: undefined, - }; + constructor(props: IntersectionObserverProps | PlainChildrenProps) { + super(props); + this.state = { + inView: !!props.initialInView, + entry: undefined, + }; + } - componentDidUpdate(prevProps: IntersectionObserverProps, prevState: State) { + componentDidUpdate(prevProps: IntersectionObserverProps) { // If a IntersectionObserver option changed, reinit the observer if ( prevProps.rootMargin !== this.props.rootMargin || @@ -83,7 +87,7 @@ export class InView extends React.Component< if (!node && !this.props.triggerOnce && !this.props.skip) { // Reset the state if we get a new node, and we aren't ignoring updates - this.setState({ inView: false, entry: undefined }); + this.setState({ inView: !!this.props.initialInView, entry: undefined }); } } this.node = node ? node : null; @@ -124,6 +128,7 @@ export class InView extends React.Component< skip, trackVisibility, delay, + initialInView, ...props } = this.props; diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx index 41d53695..947cc767 100644 --- a/src/__tests__/InView.test.tsx +++ b/src/__tests__/InView.test.tsx @@ -51,6 +51,17 @@ it('Should respect skip', () => { expect(cb).not.toHaveBeenCalled(); }); + +it('Should handle initialInView', () => { + const cb = jest.fn(); + render( + + {({ inView }) => `InView: ${inView}`} + , + ); + screen.getByText('InView: true'); +}); + it('Should unobserve old node', () => { const { rerender } = render( diff --git a/src/__tests__/hooks.test.tsx b/src/__tests__/hooks.test.tsx index dd4c2ddd..b8b6041e 100644 --- a/src/__tests__/hooks.test.tsx +++ b/src/__tests__/hooks.test.tsx @@ -57,6 +57,15 @@ test('should create a hook inView', () => { getByText('true'); }); +test('should create a hook with initialInView', () => { + const { getByText } = render( + , + ); + getByText('true'); + mockAllIsIntersecting(false); + getByText('false'); +}); + test('should trigger a hook leaving view', () => { const { getByText } = render(); mockAllIsIntersecting(true); diff --git a/src/__tests__/observers.test.ts b/src/__tests__/observers.test.ts index f89d4e3e..9d11004c 100644 --- a/src/__tests__/observers.test.ts +++ b/src/__tests__/observers.test.ts @@ -7,12 +7,31 @@ test('should convert options to id', () => { rootMargin: '10px 10px', threshold: [0, 1], }), - ).toMatchInlineSnapshot(`"root_1|rootMargin_10px 10px|threshold_0,1"`); + ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`); expect( optionsToId({ root: null, rootMargin: '10px 10px', threshold: 1, }), - ).toMatchInlineSnapshot(`"root_|rootMargin_10px 10px|threshold_1"`); + ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`); + expect( + optionsToId({ + threshold: 0, + // @ts-ignore + trackVisibility: true, + // @ts-ignore + delay: 500, + }), + ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`); + expect( + optionsToId({ + threshold: 0, + }), + ).toMatchInlineSnapshot(`"threshold_0"`); + expect( + optionsToId({ + threshold: [0, 0.5, 1], + }), + ).toMatchInlineSnapshot(`"threshold_0,0.5,1"`); }); diff --git a/src/index.tsx b/src/index.tsx index e4b56248..a584b083 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,6 +31,7 @@ export interface IntersectionOptions extends IntersectionObserverInit { triggerOnce?: boolean; /** Skip assigning the observer to the `ref` */ skip?: boolean; + initialInView?: boolean; /** IntersectionObserver v2 - Track the actual visibility of the element */ trackVisibility?: boolean; /** IntersectionObserver v2 - Set a minimum delay between notifications */ diff --git a/src/observers.ts b/src/observers.ts index 59390879..f38fe49f 100644 --- a/src/observers.ts +++ b/src/observers.ts @@ -11,34 +11,35 @@ const ObserverMap = new Map< const RootIds: Map = new Map(); -let consecutiveRootId = 0; +let rootId = 0; /** * Generate a unique ID for the root element * @param root */ function getRootId(root?: Element | null) { - if (!root) return ''; + if (!root) return '0'; if (RootIds.has(root)) return RootIds.get(root); - consecutiveRootId += 1; - RootIds.set(root, consecutiveRootId.toString()); + rootId += 1; + RootIds.set(root, rootId.toString()); return RootIds.get(root); } /** * Convert the options to a string Id, based on the values. - * Ensures we can reuse the same observer for, when observer elements with the same options. + * Ensures we can reuse the same observer when observing elements with the same options. * @param options */ export function optionsToId(options: IntersectionObserverInit) { return Object.keys(options) + .filter(Boolean) .sort() .map((key) => { return `${key}_${ key === 'root' ? getRootId(options.root) : options[key] }`; }) - .join('|'); + .toString(); } function createObserver(options: IntersectionObserverInit) { diff --git a/src/stories/Hooks.story.tsx b/src/stories/Hooks.story.tsx index d52da049..5f02c49c 100644 --- a/src/stories/Hooks.story.tsx +++ b/src/stories/Hooks.story.tsx @@ -83,7 +83,9 @@ export const lazyHookRendering = () => ( ); -export const startInView = () => ; +export const startInView = () => ( + +); export const tallerThanViewport = () => ( diff --git a/src/useInView.tsx b/src/useInView.tsx index 314e3c60..3f642d71 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,23 +1,27 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import * as React from 'react'; import { InViewHookResponse, IntersectionOptions } from './index'; import { useEffect } from 'react'; import { observe } from './observers'; + type State = { inView: boolean; entry?: IntersectionObserverEntry; }; -const initialState: State = { - inView: false, - entry: undefined, -}; - -export function useInView( - options: IntersectionOptions = {}, -): InViewHookResponse { +export function useInView({ + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + initialInView, +}: IntersectionOptions = {}): InViewHookResponse { const unobserve = React.useRef(); - const [state, setState] = React.useState(initialState); + const [state, setState] = React.useState({ + inView: !!initialInView, + }); const setRef = React.useCallback( (node) => { @@ -26,9 +30,8 @@ export function useInView( unobserve.current = undefined; } - if (options.skip) { - return; - } + // Skip creating the observer + if (skip) return; if (node) { unobserve.current = observe( @@ -36,36 +39,35 @@ export function useInView( (inView, entry) => { setState({ inView, entry }); - if ( - entry.isIntersecting && - options.triggerOnce && - unobserve.current - ) { + if (entry.isIntersecting && triggerOnce && unobserve.current) { // If it should only trigger once, unobserve the element after it's inView unobserve.current(); unobserve.current = undefined; } }, - options, + { + root, + rootMargin, + threshold, + // @ts-ignore + trackVisibility, + // @ts-ignore + delay, + }, ); } }, - [ - options.threshold, - options.root, - options.rootMargin, - options.triggerOnce, - options.skip, - options.trackVisibility, - options.delay, - ], + [threshold, root, rootMargin, triggerOnce, skip, trackVisibility, delay], ); + /* eslint-disable-next-line */ useEffect(() => { - if (!unobserve.current && !options.triggerOnce && !options.skip) { + if (!unobserve.current && state.entry && !triggerOnce && !skip) { // If we don't have a ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`) // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView - setState(initialState); + setState({ + inView: !!initialInView, + }); } });