Skip to content

Commit 021dde1

Browse files
committed
fix: preserve React 17 fallback callbacks
1 parent 4074dc6 commit 021dde1

4 files changed

Lines changed: 95 additions & 32 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"path": "dist/index.mjs",
102102
"name": "useInView",
103103
"import": "{ useInView }",
104-
"limit": "1.32 kB"
104+
"limit": "1.36 kB"
105105
},
106106
{
107107
"path": "dist/index.mjs",

‎src/__tests__/useInView.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,37 @@ test("should handle fallback if unsupported", () => {
481481
);
482482
});
483483

484+
test("should use the latest onChange when fallback reattaches synchronously", () => {
485+
destroyIntersectionMocking();
486+
// @ts-expect-error
487+
window.IntersectionObserver = undefined;
488+
const firstOnChange = vi.fn();
489+
const secondOnChange = vi.fn();
490+
const { rerender } = render(
491+
<HookComponent
492+
options={{
493+
fallbackInView: true,
494+
onChange: firstOnChange,
495+
threshold: 0,
496+
}}
497+
/>,
498+
);
499+
firstOnChange.mockClear();
500+
501+
rerender(
502+
<HookComponent
503+
options={{
504+
fallbackInView: true,
505+
onChange: secondOnChange,
506+
threshold: 1,
507+
}}
508+
/>,
509+
);
510+
511+
expect(firstOnChange).not.toHaveBeenCalled();
512+
expect(secondOnChange).toHaveBeenCalledOnce();
513+
});
514+
484515
test("should handle defaultFallbackInView if unsupported", () => {
485516
destroyIntersectionMocking();
486517
// @ts-expect-error

‎src/useInView.tsx‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ type State = {
1010
entry?: IntersectionObserverEntry;
1111
};
1212

13+
type RefState = {
14+
node: Element | null;
15+
reset: boolean;
16+
};
17+
1318
/**
1419
* React Hooks make it easy to monitor the `inView` state of your components. Call
1520
* the `useInView` hook with the (optional) [options](#options) you need. It will
@@ -81,27 +86,38 @@ export function useInView({
8186
},
8287
);
8388

84-
const refState = React.useRef<Element | false | null>(null);
89+
const refState = React.useRef<RefState>({
90+
node: null,
91+
reset: false,
92+
});
8593

8694
const setRef = React.useCallback(
8795
function setRef(node?: Element | null) {
88-
if (node) refState.current = node;
89-
else if (refState.current) refState.current = false;
96+
if (node) {
97+
refState.current.node = node;
98+
refState.current.reset = false;
99+
} else if (refState.current.node) {
100+
refState.current.node = null;
101+
refState.current.reset = true;
102+
}
90103

91104
const cleanup = observerRef(node);
92105
if (!cleanup) return;
93106

94107
return () => {
95108
cleanup();
96-
if (refState.current === node) refState.current = false;
109+
if (refState.current.node === node) {
110+
refState.current.node = null;
111+
refState.current.reset = true;
112+
}
97113
};
98114
},
99115
[observerRef],
100116
);
101117

102118
useIsomorphicLayoutEffect(() => {
103-
if (refState.current !== false) return;
104-
refState.current = null;
119+
if (!refState.current.reset) return;
120+
refState.current.reset = false;
105121
if (triggerOnce || skip) return;
106122

107123
setState({ inView: !!initialInView, entry: undefined });

‎src/useIntersectionObserverRef.ts‎

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { observe } from "./observe";
55
export const useIsomorphicLayoutEffect =
66
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
77

8-
const useSyncEffect =
9-
Reflect.get(React, "useInsertionEffect") || useIsomorphicLayoutEffect;
8+
const useInsertionEffect = Reflect.get(React, "useInsertionEffect") as
9+
| typeof React.useEffect
10+
| undefined;
11+
const useSyncEffect = useInsertionEffect ?? React.useEffect;
1012

1113
export function supportsRefCleanup(version: string | undefined) {
1214
return version?.startsWith("19.") || false;
@@ -26,6 +28,16 @@ type ObserverRefOptions = IntersectionObserverInitWithOptions & {
2628
triggerOnce?: boolean;
2729
};
2830

31+
type ObserverRefCallback<TElement extends Element> = (
32+
element: TElement | undefined | null,
33+
) => (() => void) | undefined;
34+
35+
type ObserverState<TElement extends Element> = {
36+
node: TElement | null;
37+
stop: (() => void) | undefined;
38+
owner: ObserverRefCallback<TElement> | null;
39+
};
40+
2941
export function useIntersectionObserverRef<TElement extends Element>(
3042
onIntersectionChange: ObserverCallback<TElement>,
3143
{
@@ -41,13 +53,17 @@ export function useIntersectionObserverRef<TElement extends Element>(
4153
}: ObserverRefOptions,
4254
) {
4355
const onIntersectionChangeRef = React.useRef(onIntersectionChange);
44-
const observerStateRef = React.useRef<
45-
[
46-
TElement | null,
47-
(() => void) | undefined,
48-
((element?: TElement | null) => void) | null,
49-
]
50-
>([null, undefined, null]);
56+
const observerStateRef = React.useRef<ObserverState<TElement>>({
57+
node: null,
58+
stop: undefined,
59+
owner: null,
60+
});
61+
62+
// React 17 has no effect that runs before callback refs attach. Keep its
63+
// synchronous fallback behavior compatible by publishing during render.
64+
if (!useInsertionEffect) {
65+
onIntersectionChangeRef.current = onIntersectionChange;
66+
}
5167

5268
useSyncEffect(() => {
5369
onIntersectionChangeRef.current = onIntersectionChange;
@@ -58,41 +74,41 @@ export function useIntersectionObserverRef<TElement extends Element>(
5874
function setRef(element: TElement | undefined | null) {
5975
const observerState = observerStateRef.current;
6076

61-
if (!element && observerState[2] !== setRef) {
77+
if (!element && observerState.owner !== setRef) {
6278
return;
6379
}
6480

65-
if (element === observerState[0]) {
66-
observerState[2] = setRef;
67-
return canUseRefCleanup ? observerState[1] : undefined;
81+
if (element === observerState.node) {
82+
observerState.owner = setRef;
83+
return canUseRefCleanup ? observerState.stop : undefined;
6884
}
6985

70-
const cleanup = observerState[1];
71-
observerState[1] = undefined;
86+
const cleanup = observerState.stop;
87+
observerState.stop = undefined;
7288
cleanup?.();
7389

7490
if (!element || skip) {
75-
observerState[0] = null;
76-
observerState[2] = element ? setRef : null;
91+
observerState.node = null;
92+
observerState.owner = element ? setRef : null;
7793
return;
7894
}
7995

80-
observerState[0] = element;
81-
observerState[2] = setRef;
96+
observerState.node = element;
97+
observerState.owner = setRef;
8298

8399
let destroyObserver: (() => void) | undefined;
84100
let previousInView: boolean | undefined;
85101

86102
function stopObserving() {
87103
destroyObserver?.();
88104

89-
if (observerState[1] === stopObserving) {
90-
observerState[0] = null;
91-
observerState[1] = undefined;
105+
if (observerState.stop === stopObserving) {
106+
observerState.node = null;
107+
observerState.stop = undefined;
92108
}
93109
}
94110

95-
observerState[1] = stopObserving;
111+
observerState.stop = stopObserving;
96112
destroyObserver = observe(
97113
element,
98114
(inView, entry) => {
@@ -115,9 +131,9 @@ export function useIntersectionObserverRef<TElement extends Element>(
115131
fallbackInView,
116132
);
117133

118-
if (observerState[1] !== stopObserving) destroyObserver();
134+
if (observerState.stop !== stopObserving) destroyObserver();
119135

120-
return canUseRefCleanup ? observerState[1] : undefined;
136+
return canUseRefCleanup ? observerState.stop : undefined;
121137
},
122138
[
123139
Array.isArray(threshold) ? threshold.toString() : threshold,

0 commit comments

Comments
 (0)