Skip to content

Commit

Permalink
feat: support initialInView (#394)
Browse files Browse the repository at this point in the history
Add support for the `initialInView` option
  • Loading branch information
thebuilder authored Sep 28, 2020
1 parent da12682 commit 2ce78b6
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 45 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 11 additions & 6 deletions src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -124,6 +128,7 @@ export class InView extends React.Component<
skip,
trackVisibility,
delay,
initialInView,
...props
} = this.props;

Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ it('Should respect skip', () => {

expect(cb).not.toHaveBeenCalled();
});

it('Should handle initialInView', () => {
const cb = jest.fn();
render(
<InView initialInView onChange={cb}>
{({ inView }) => `InView: ${inView}`}
</InView>,
);
screen.getByText('InView: true');
});

it('Should unobserve old node', () => {
const { rerender } = render(
<InView>
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ test('should create a hook inView', () => {
getByText('true');
});

test('should create a hook with initialInView', () => {
const { getByText } = render(
<HookComponent options={{ initialInView: true }} />,
);
getByText('true');
mockAllIsIntersecting(false);
getByText('false');
});

test('should trigger a hook leaving view', () => {
const { getByText } = render(<HookComponent />);
mockAllIsIntersecting(true);
Expand Down
23 changes: 21 additions & 2 deletions src/__tests__/observers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`);
});
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
13 changes: 7 additions & 6 deletions src/observers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,35 @@ const ObserverMap = new Map<

const RootIds: Map<Element, string> = 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) {
Expand Down
4 changes: 3 additions & 1 deletion src/stories/Hooks.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export const lazyHookRendering = () => (
</ScrollWrapper>
);

export const startInView = () => <HookComponent />;
export const startInView = () => (
<HookComponent options={{ initialInView: true }} />
);

export const tallerThanViewport = () => (
<ScrollWrapper>
Expand Down
62 changes: 32 additions & 30 deletions src/useInView.tsx
Original file line number Diff line number Diff line change
@@ -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<Function>();
const [state, setState] = React.useState<State>(initialState);
const [state, setState] = React.useState<State>({
inView: !!initialInView,
});

const setRef = React.useCallback(
(node) => {
Expand All @@ -26,46 +30,44 @@ export function useInView(
unobserve.current = undefined;
}

if (options.skip) {
return;
}
// Skip creating the observer
if (skip) return;

if (node) {
unobserve.current = observe(
node,
(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,
});
}
});

Expand Down

1 comment on commit 2ce78b6

@vercel
Copy link

@vercel vercel bot commented on 2ce78b6 Sep 28, 2020

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.