Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rewrite internals to use setState for ref #573

Merged
merged 9 commits into from
Jul 22, 2022
Merged
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ module.exports = {
'no-this-before-super': 'warn',
'no-throw-literal': 'warn',
'no-unexpected-multiline': 'warn',
'no-unreachable': 'wa' + 'rn',
'no-unreachable': 'warn',
'no-unused-expressions': [
'error',
{
Expand Down
2 changes: 1 addition & 1 deletion .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = {
async viteFinal(config) {
// The build fails to correctly minify the `ansi-to-html` module with esbuild, so we fallback to Terser.
// It's a package used by "Storybook" for the Webpreview, so it's interesting why it fails.
config.build.minify = 'terser';
if (config.build) config.build.minify = 'terser';

if (config.optimizeDeps) {
config.optimizeDeps.include = [
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ Provide these as the options argument in the `useInView` hook or as props on the

The **`<InView />`** component also accepts the following props:

| Name | Type | Default | Description |
| ------------ | ---------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **as** | `string` | `'div'` | Render the wrapping element as this element. Defaults to `div`. |
| Name | Type | Default | Description |
| ------------ | ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **as** | `string` | `'div'` | Render the wrapping element as this element. Defaults to `div`. |
| **children** | `({ref, inView, entry}) => ReactNode` or `ReactNode` | `undefined` | Children expects a function that receives an object containing the `inView` boolean and a `ref` that should be assigned to the element root. Alternatively pass a plain child, to have the `<InView />` deal with the wrapping element. You will also get the `IntersectionObserverEntry` as `entry`, giving you more details. |

### Intersection Observer v2 🧪
Expand Down Expand Up @@ -217,9 +217,9 @@ import { useInView } from 'react-intersection-observer';

function Component(props) {
const ref = useRef();
const [inViewRef, inView] = useInView();
const { ref: inViewRef, inView } = useInView();

// Use `useCallback` so we don't recreate the function on each render - Could result in infinite loop
// Use `useCallback` so we don't recreate the function on each render
const setRefs = useCallback(
(node) => {
// Ref's from useRef needs to have the node assigned to `current`
Expand Down
28 changes: 23 additions & 5 deletions src/stories/Hooks.story.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { action } from '@storybook/addon-actions';
import { Meta, Story } from '@storybook/react';
import { IntersectionOptions, InView, useInView } from '../index';
import { motion } from 'framer-motion';
Expand Down Expand Up @@ -28,6 +27,7 @@ type Props = IntersectionOptions & {
style?: CSSProperties;
className?: string;
lazy?: boolean;
inlineRef?: boolean;
};

const story: Meta = {
Expand Down Expand Up @@ -79,6 +79,7 @@ const story: Meta = {
table: {
disable: true,
},
action: 'InView',
},
},
args: {
Expand All @@ -88,11 +89,19 @@ const story: Meta = {

export default story;

const Template: Story<Props> = ({ style, className, lazy, ...rest }) => {
const Template: Story<Props> = ({
style,
className,
lazy,
inlineRef,
...rest
}) => {
// const onChange: IntersectionOptions['onChange'] = (inView, entry) => {
// action('InView')(inView, entry);
// }
const { options, error } = useValidateOptions(rest);
const { ref, inView, entry } = useInView(!error ? options : {});
const { ref, inView } = useInView(!error ? { ...options } : {});
const [isLoading, setIsLoading] = useState(lazy);
action('InView')(inView, entry);

useEffect(() => {
if (isLoading) setIsLoading(false);
Expand All @@ -109,7 +118,11 @@ const Template: Story<Props> = ({ style, className, lazy, ...rest }) => {
return (
<ScrollWrapper indicators={options.initialInView ? 'bottom' : 'all'}>
<Status inView={inView} />
<InViewBlock ref={ref} inView={inView} style={style}>
<InViewBlock
ref={inlineRef ? (node) => ref(node) : ref}
inView={inView}
style={style}
>
<InViewIcon inView={inView} />
<EntryDetails options={options} />
</InViewBlock>
Expand All @@ -127,6 +140,11 @@ LazyHookRendering.args = {
lazy: true,
};

export const InlineRef = Template.bind({});
InlineRef.args = {
inlineRef: true,
};

export const StartInView = Template.bind({});
StartInView.args = {
initialInView: true,
Expand Down
86 changes: 47 additions & 39 deletions src/useInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,56 +45,62 @@ export function useInView({
fallbackInView,
onChange,
}: IntersectionOptions = {}): InViewHookResponse {
const unobserve = React.useRef<Function>();
const [ref, setRef] = React.useState<Element | null>(null);
const callback = React.useRef<IntersectionOptions['onChange']>();
const [state, setState] = React.useState<State>({
inView: !!initialInView,
entry: undefined,
});
// Store the onChange callback in a `ref`, so we can access the latest instance inside the `useCallback`.

// Store the onChange callback in a `ref`, so we can access the latest instance
// inside the `useEffect`, but without triggering a rerender.
callback.current = onChange;

const setRef = React.useCallback(
(node: Element | null) => {
if (unobserve.current !== undefined) {
unobserve.current();
unobserve.current = undefined;
}
React.useEffect(
() => {
// Ensure we have node ref, and that we shouldn't skip observing
if (skip || !ref) return;

// Skip creating the observer
if (skip) return;
let unobserve: (() => void) | undefined = observe(
ref,
(inView, entry) => {
setState({
inView,
entry,
});
if (callback.current) callback.current(inView, entry);

if (node) {
unobserve.current = observe(
node,
(inView, entry) => {
setState({ inView, entry });
if (callback.current) callback.current(inView, entry);
if (entry.isIntersecting && triggerOnce && unobserve) {
// If it should only trigger once, unobserve the element after it's inView
unobserve();
unobserve = undefined;
}
},
{
root,
rootMargin,
threshold,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
},
fallbackInView,
);

if (entry.isIntersecting && triggerOnce && unobserve.current) {
// If it should only trigger once, unobserve the element after it's inView
unobserve.current();
unobserve.current = undefined;
}
},
{
root,
rootMargin,
threshold,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
},
fallbackInView,
);
}
return () => {
if (unobserve) {
unobserve();
}
};
thebuilder marked this conversation as resolved.
Show resolved Hide resolved
},
// We break the rule here, because we aren't including the actual `threshold` variable
// eslint-disable-next-line react-hooks/exhaustive-deps
[
// If the threshold is an array, convert it to a string so it won't change between renders.
// If the threshold is an array, convert it to a string, so it won't change between renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
Array.isArray(threshold) ? threshold.toString() : threshold,
ref,
root,
rootMargin,
triggerOnce,
Expand All @@ -105,16 +111,18 @@ export function useInView({
],
);

/* eslint-disable-next-line */
const entryTarget = state.entry?.target;

React.useEffect(() => {
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`)
if (!ref && entryTarget && !triggerOnce && !skip) {
// If we don't have a node 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({
inView: !!initialInView,
entry: undefined,
});
}
});
}, [ref, entryTarget, triggerOnce, skip, initialInView]);

const result = [setRef, state.inView, state.entry] as InViewHookResponse;

Expand Down