Skip to content

Commit 465754a

Browse files
authored
Merge pull request #573 from thebuilder/feat/state-batching
feat: rewrite internals to use `setState` for `ref`
2 parents 95c3fa7 + b6cecf6 commit 465754a

5 files changed

Lines changed: 77 additions & 51 deletions

File tree

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ module.exports = {
9191
'no-this-before-super': 'warn',
9292
'no-throw-literal': 'warn',
9393
'no-unexpected-multiline': 'warn',
94-
'no-unreachable': 'wa' + 'rn',
94+
'no-unreachable': 'warn',
9595
'no-unused-expressions': [
9696
'error',
9797
{

.storybook/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ module.exports = {
3030
async viteFinal(config) {
3131
// The build fails to correctly minify the `ansi-to-html` module with esbuild, so we fallback to Terser.
3232
// It's a package used by "Storybook" for the Webpreview, so it's interesting why it fails.
33-
config.build.minify = 'terser';
33+
if (config.build) config.build.minify = 'terser';
3434

3535
if (config.optimizeDeps) {
3636
config.optimizeDeps.include = [

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,9 @@ Provide these as the options argument in the `useInView` hook or as props on the
165165

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

168-
| Name | Type | Default | Description |
169-
| ------------ | ---------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
170-
| **as** | `string` | `'div'` | Render the wrapping element as this element. Defaults to `div`. |
168+
| Name | Type | Default | Description |
169+
| ------------ | ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
170+
| **as** | `string` | `'div'` | Render the wrapping element as this element. Defaults to `div`. |
171171
| **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. |
172172

173173
### Intersection Observer v2 🧪
@@ -218,9 +218,9 @@ import { useInView } from 'react-intersection-observer';
218218

219219
function Component(props) {
220220
const ref = useRef();
221-
const [inViewRef, inView] = useInView();
221+
const { ref: inViewRef, inView } = useInView();
222222

223-
// Use `useCallback` so we don't recreate the function on each render - Could result in infinite loop
223+
// Use `useCallback` so we don't recreate the function on each render
224224
const setRefs = useCallback(
225225
(node) => {
226226
// Ref's from useRef needs to have the node assigned to `current`

src/stories/Hooks.story.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { action } from '@storybook/addon-actions';
21
import { Meta, Story } from '@storybook/react';
32
import { IntersectionOptions, InView, useInView } from '../index';
43
import { motion } from 'framer-motion';
@@ -28,6 +27,7 @@ type Props = IntersectionOptions & {
2827
style?: CSSProperties;
2928
className?: string;
3029
lazy?: boolean;
30+
inlineRef?: boolean;
3131
};
3232

3333
const story: Meta = {
@@ -79,6 +79,7 @@ const story: Meta = {
7979
table: {
8080
disable: true,
8181
},
82+
action: 'InView',
8283
},
8384
},
8485
args: {
@@ -88,11 +89,19 @@ const story: Meta = {
8889

8990
export default story;
9091

91-
const Template: Story<Props> = ({ style, className, lazy, ...rest }) => {
92+
const Template: Story<Props> = ({
93+
style,
94+
className,
95+
lazy,
96+
inlineRef,
97+
...rest
98+
}) => {
99+
// const onChange: IntersectionOptions['onChange'] = (inView, entry) => {
100+
// action('InView')(inView, entry);
101+
// }
92102
const { options, error } = useValidateOptions(rest);
93-
const { ref, inView, entry } = useInView(!error ? options : {});
103+
const { ref, inView } = useInView(!error ? { ...options } : {});
94104
const [isLoading, setIsLoading] = useState(lazy);
95-
action('InView')(inView, entry);
96105

97106
useEffect(() => {
98107
if (isLoading) setIsLoading(false);
@@ -109,7 +118,11 @@ const Template: Story<Props> = ({ style, className, lazy, ...rest }) => {
109118
return (
110119
<ScrollWrapper indicators={options.initialInView ? 'bottom' : 'all'}>
111120
<Status inView={inView} />
112-
<InViewBlock ref={ref} inView={inView} style={style}>
121+
<InViewBlock
122+
ref={inlineRef ? (node) => ref(node) : ref}
123+
inView={inView}
124+
style={style}
125+
>
113126
<InViewIcon inView={inView} />
114127
<EntryDetails options={options} />
115128
</InViewBlock>
@@ -127,6 +140,11 @@ LazyHookRendering.args = {
127140
lazy: true,
128141
};
129142

143+
export const InlineRef = Template.bind({});
144+
InlineRef.args = {
145+
inlineRef: true,
146+
};
147+
130148
export const StartInView = Template.bind({});
131149
StartInView.args = {
132150
initialInView: true,

src/useInView.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -45,56 +45,62 @@ export function useInView({
4545
fallbackInView,
4646
onChange,
4747
}: IntersectionOptions = {}): InViewHookResponse {
48-
const unobserve = React.useRef<Function>();
48+
const [ref, setRef] = React.useState<Element | null>(null);
4949
const callback = React.useRef<IntersectionOptions['onChange']>();
5050
const [state, setState] = React.useState<State>({
5151
inView: !!initialInView,
52+
entry: undefined,
5253
});
53-
// Store the onChange callback in a `ref`, so we can access the latest instance inside the `useCallback`.
54+
55+
// Store the onChange callback in a `ref`, so we can access the latest instance
56+
// inside the `useEffect`, but without triggering a rerender.
5457
callback.current = onChange;
5558

56-
const setRef = React.useCallback(
57-
(node: Element | null) => {
58-
if (unobserve.current !== undefined) {
59-
unobserve.current();
60-
unobserve.current = undefined;
61-
}
59+
React.useEffect(
60+
() => {
61+
// Ensure we have node ref, and that we shouldn't skip observing
62+
if (skip || !ref) return;
6263

63-
// Skip creating the observer
64-
if (skip) return;
64+
let unobserve: (() => void) | undefined = observe(
65+
ref,
66+
(inView, entry) => {
67+
setState({
68+
inView,
69+
entry,
70+
});
71+
if (callback.current) callback.current(inView, entry);
6572

66-
if (node) {
67-
unobserve.current = observe(
68-
node,
69-
(inView, entry) => {
70-
setState({ inView, entry });
71-
if (callback.current) callback.current(inView, entry);
73+
if (entry.isIntersecting && triggerOnce && unobserve) {
74+
// If it should only trigger once, unobserve the element after it's inView
75+
unobserve();
76+
unobserve = undefined;
77+
}
78+
},
79+
{
80+
root,
81+
rootMargin,
82+
threshold,
83+
// @ts-ignore
84+
trackVisibility,
85+
// @ts-ignore
86+
delay,
87+
},
88+
fallbackInView,
89+
);
7290

73-
if (entry.isIntersecting && triggerOnce && unobserve.current) {
74-
// If it should only trigger once, unobserve the element after it's inView
75-
unobserve.current();
76-
unobserve.current = undefined;
77-
}
78-
},
79-
{
80-
root,
81-
rootMargin,
82-
threshold,
83-
// @ts-ignore
84-
trackVisibility,
85-
// @ts-ignore
86-
delay,
87-
},
88-
fallbackInView,
89-
);
90-
}
91+
return () => {
92+
if (unobserve) {
93+
unobserve();
94+
}
95+
};
9196
},
9297
// We break the rule here, because we aren't including the actual `threshold` variable
9398
// eslint-disable-next-line react-hooks/exhaustive-deps
9499
[
95-
// If the threshold is an array, convert it to a string so it won't change between renders.
100+
// If the threshold is an array, convert it to a string, so it won't change between renders.
96101
// eslint-disable-next-line react-hooks/exhaustive-deps
97102
Array.isArray(threshold) ? threshold.toString() : threshold,
103+
ref,
98104
root,
99105
rootMargin,
100106
triggerOnce,
@@ -105,16 +111,18 @@ export function useInView({
105111
],
106112
);
107113

108-
/* eslint-disable-next-line */
114+
const entryTarget = state.entry?.target;
115+
109116
React.useEffect(() => {
110-
if (!unobserve.current && state.entry && !triggerOnce && !skip) {
111-
// If we don't have a ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`)
117+
if (!ref && entryTarget && !triggerOnce && !skip) {
118+
// If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`)
112119
// This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
113120
setState({
114121
inView: !!initialInView,
122+
entry: undefined,
115123
});
116124
}
117-
});
125+
}, [ref, entryTarget, triggerOnce, skip, initialInView]);
118126

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

0 commit comments

Comments
 (0)