diff --git a/.eslintrc.js b/.eslintrc.js index 7cbe9d0e..2385fdd2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', { diff --git a/.storybook/main.js b/.storybook/main.js index dd1404ae..4c2991e9 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -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 = [ diff --git a/README.md b/README.md index 50baff71..8ec7a2ce 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,9 @@ Provide these as the options argument in the `useInView` hook or as props on the The **``** 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 `` deal with the wrapping element. You will also get the `IntersectionObserverEntry` as `entry`, giving you more details. | ### Intersection Observer v2 🧪 @@ -218,9 +218,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` diff --git a/src/stories/Hooks.story.tsx b/src/stories/Hooks.story.tsx index 33f9108f..5f90754b 100644 --- a/src/stories/Hooks.story.tsx +++ b/src/stories/Hooks.story.tsx @@ -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'; @@ -28,6 +27,7 @@ type Props = IntersectionOptions & { style?: CSSProperties; className?: string; lazy?: boolean; + inlineRef?: boolean; }; const story: Meta = { @@ -79,6 +79,7 @@ const story: Meta = { table: { disable: true, }, + action: 'InView', }, }, args: { @@ -88,11 +89,19 @@ const story: Meta = { export default story; -const Template: Story = ({ style, className, lazy, ...rest }) => { +const Template: Story = ({ + 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); @@ -109,7 +118,11 @@ const Template: Story = ({ style, className, lazy, ...rest }) => { return ( - + ref(node) : ref} + inView={inView} + style={style} + > @@ -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, diff --git a/src/useInView.tsx b/src/useInView.tsx index c8dd8689..a7a89a04 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -45,56 +45,62 @@ export function useInView({ fallbackInView, onChange, }: IntersectionOptions = {}): InViewHookResponse { - const unobserve = React.useRef(); + const [ref, setRef] = React.useState(null); const callback = React.useRef(); const [state, setState] = React.useState({ 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(); + } + }; }, // 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, @@ -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;