Skip to content

Commit

Permalink
Merge pull request #573 from thebuilder/feat/state-batching
Browse files Browse the repository at this point in the history
feat: rewrite internals to use `setState` for `ref`
  • Loading branch information
thebuilder authored Jul 22, 2022
2 parents 95c3fa7 + b6cecf6 commit 465754a
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 51 deletions.
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 @@ -165,9 +165,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 @@ -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`
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();
}
};
},
// 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

1 comment on commit 465754a

@vercel
Copy link

@vercel vercel bot commented on 465754a Jul 22, 2022

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.