Skip to content

Commit b5f158d

Browse files
authored
refactor: share callback-ref observation between hooks (#765)
* refactor: extract callback ref observer core * refactor: move useInView to callback ref observation * test: verify callback ref render improvement * refactor: simplify useInView detach tracking * chore: adjust useInView size limit * fix: preserve useOnInView declaration spelling * refactor: colocate ref cleanup support detection * fix: keep initialInView non-reactive * fix: avoid hook SSR diagnostics * test: use TypeScript for SSR compatibility fixture * fix: typecheck packed SSR fixture * refactor: declare useOnInView return type * test: add node SSR project * test: move SSR coverage into Vitest * test: accept React 17 SSR root marker * fix: preserve React 17 fallback callbacks * refactor: keep reset timing in useInView
1 parent 062a7c4 commit b5f158d

9 files changed

Lines changed: 309 additions & 190 deletions

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,13 @@
101101
"path": "dist/index.mjs",
102102
"name": "useInView",
103103
"import": "{ useInView }",
104-
"limit": "1.3 kB"
104+
"limit": "1.36 kB"
105105
},
106106
{
107107
"path": "dist/index.mjs",
108108
"name": "useOnInView",
109109
"import": "{ useOnInView }",
110-
"limit": "1.1 kB"
110+
"limit": "1.12 kB"
111111
},
112112
{
113113
"path": "dist/index.mjs",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createElement } from "react";
2+
import { renderToString } from "react-dom/server";
3+
import { useInView } from "../useInView";
4+
5+
test("useInView renders on the server without diagnostics", () => {
6+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
7+
8+
try {
9+
function CompatibilityFixture() {
10+
const { ref, inView } = useInView();
11+
return createElement("div", { ref }, String(inView));
12+
}
13+
14+
expect(renderToString(createElement(CompatibilityFixture))).toContain(
15+
">false</div>",
16+
);
17+
expect(consoleError).not.toHaveBeenCalled();
18+
} finally {
19+
consoleError.mockRestore();
20+
}
21+
});

‎src/__tests__/useInView.test.tsx‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ test("should create a hook with initialInView", () => {
136136
getByText("false");
137137
});
138138

139+
test("should not react to initialInView changes before the first notification", () => {
140+
const onChange = vi.fn();
141+
const { rerender } = render(<HookComponent options={{ onChange }} />);
142+
143+
rerender(<HookComponent options={{ initialInView: true, onChange }} />);
144+
mockAllIsIntersecting(false);
145+
146+
expect(onChange).not.toHaveBeenCalled();
147+
});
148+
139149
test("should trigger a hook leaving view", () => {
140150
const { getByText } = render(<HookComponent />);
141151
mockAllIsIntersecting(true);
@@ -471,6 +481,37 @@ test("should handle fallback if unsupported", () => {
471481
);
472482
});
473483

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+
474515
test("should handle defaultFallbackInView if unsupported", () => {
475516
destroyIntersectionMocking();
476517
// @ts-expect-error
@@ -542,7 +583,7 @@ test("should trigger all hooks when using triggerOnce with merged refs", () => {
542583
expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true");
543584
});
544585

545-
test("baseline: mounting useInView commits once more after storing the target", () => {
586+
test("mounting useInView does not cause an attachment rerender", () => {
546587
const onRender = vi.fn();
547588
const onCommit = vi.fn();
548589

@@ -552,9 +593,8 @@ test("baseline: mounting useInView commits once more after storing the target",
552593
</React.Profiler>,
553594
);
554595

555-
// Plan 006 is expected to remove the target-state render and update this baseline.
556-
expect(onRender).toHaveBeenCalledTimes(2);
557-
expect(onCommit).toHaveBeenCalledTimes(2);
596+
expect(onRender).toHaveBeenCalledTimes(1);
597+
expect(onCommit).toHaveBeenCalledTimes(1);
558598
expect(window.IntersectionObserver).toHaveBeenCalledTimes(1);
559599
});
560600

‎src/__tests__/useOnInView.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { render, screen } from "@testing-library/react";
22
import { Profiler, StrictMode, useCallback, useEffect, useState } from "react";
33
import type { IntersectionChangeEffect, IntersectionEffectOptions } from "..";
4-
import { supportsRefCleanup } from "../refCleanupSupport";
54
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";
5+
import { supportsRefCleanup } from "../useIntersectionObserverRef";
66
import { useOnInView } from "../useOnInView";
77

88
const OnInViewChangedComponent = ({

‎src/refCleanupSupport.ts‎

Lines changed: 0 additions & 3 deletions
This file was deleted.

‎src/useInView.tsx‎

Lines changed: 60 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import * as React from "react";
22
import type { IntersectionOptions, InViewHookResponse } from "./index";
3-
import { observe } from "./observe";
3+
import { useIntersectionObserverRef } from "./useIntersectionObserverRef";
4+
5+
const useIsomorphicLayoutEffect =
6+
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
47

58
type State = {
69
inView: boolean;
710
entry?: IntersectionObserverEntry;
811
};
912

13+
type RefState = {
14+
node: Element | null;
15+
reset: boolean;
16+
};
17+
1018
/**
1119
* React Hooks make it easy to monitor the `inView` state of your components. Call
1220
* the `useInView` hook with the (optional) [options](#options) you need. It will
@@ -46,103 +54,75 @@ export function useInView({
4654
fallbackInView,
4755
onChange,
4856
}: IntersectionOptions = {}): InViewHookResponse {
49-
const [ref, setRef] = React.useState<Element | null>(null);
50-
const callback = React.useRef<IntersectionOptions["onChange"]>(onChange);
5157
const lastInViewRef = React.useRef<boolean | undefined>(initialInView);
5258
const [state, setState] = React.useState<State>({
5359
inView: !!initialInView,
5460
entry: undefined,
5561
});
5662

57-
// Store the onChange callback in a `ref`, so we can access the latest instance
58-
// inside the `useEffect`, but without triggering a rerender.
59-
callback.current = onChange;
63+
const observerRef = useIntersectionObserverRef<Element>(
64+
(inView, entry) => {
65+
const previousInView = lastInViewRef.current;
66+
lastInViewRef.current = inView;
6067

61-
// biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency
62-
React.useEffect(
63-
() => {
64-
if (lastInViewRef.current === undefined) {
65-
lastInViewRef.current = initialInView;
68+
// Ignore the very first `false` notification so consumers only hear about actual state changes.
69+
if (previousInView === undefined && !inView) {
70+
return;
6671
}
67-
// Ensure we have node ref, and that we shouldn't skip observing
68-
if (skip || !ref) return;
69-
70-
let unobserve: (() => void) | undefined;
71-
unobserve = observe(
72-
ref,
73-
(inView, entry) => {
74-
const previousInView = lastInViewRef.current;
75-
lastInViewRef.current = inView;
76-
77-
// Ignore the very first `false` notification so consumers only hear about actual state changes.
78-
if (previousInView === undefined && !inView) {
79-
return;
80-
}
81-
82-
setState({
83-
inView,
84-
entry,
85-
});
86-
if (callback.current) callback.current(inView, entry);
87-
88-
if (inView && triggerOnce && unobserve) {
89-
// If it should only trigger once, unobserve the element after it's inView
90-
unobserve();
91-
unobserve = undefined;
92-
}
93-
},
94-
{
95-
root,
96-
rootMargin,
97-
scrollMargin,
98-
threshold,
99-
trackVisibility,
100-
delay,
101-
},
102-
fallbackInView,
103-
);
10472

105-
return () => {
106-
if (unobserve) {
107-
unobserve();
108-
}
109-
};
73+
setState({ inView, entry });
74+
onChange?.(inView, entry);
11075
},
111-
// We break the rule here, because we aren't including the actual `threshold` variable
112-
// eslint-disable-next-line react-hooks/exhaustive-deps
113-
[
114-
// If the threshold is an array, convert it to a string, so it won't change between renders.
115-
Array.isArray(threshold) ? threshold.toString() : threshold,
116-
ref,
76+
{
77+
threshold,
11778
root,
11879
rootMargin,
11980
scrollMargin,
120-
triggerOnce,
121-
skip,
12281
trackVisibility,
123-
fallbackInView,
12482
delay,
125-
],
83+
fallbackInView,
84+
skip,
85+
triggerOnce,
86+
},
12687
);
12788

128-
const entryTarget = state.entry?.target;
129-
const previousEntryTarget = React.useRef<Element | undefined>(undefined);
130-
if (
131-
!ref &&
132-
entryTarget &&
133-
!triggerOnce &&
134-
!skip &&
135-
previousEntryTarget.current !== entryTarget
136-
) {
137-
// If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`)
138-
// This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
139-
previousEntryTarget.current = entryTarget;
140-
setState({
141-
inView: !!initialInView,
142-
entry: undefined,
143-
});
89+
const refState = React.useRef<RefState>({
90+
node: null,
91+
reset: false,
92+
});
93+
94+
const setRef = React.useCallback(
95+
function setRef(node?: Element | null) {
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+
}
103+
104+
const cleanup = observerRef(node);
105+
if (!cleanup) return;
106+
107+
return () => {
108+
cleanup();
109+
if (refState.current.node === node) {
110+
refState.current.node = null;
111+
refState.current.reset = true;
112+
}
113+
};
114+
},
115+
[observerRef],
116+
);
117+
118+
useIsomorphicLayoutEffect(() => {
119+
if (!refState.current.reset) return;
120+
refState.current.reset = false;
121+
if (triggerOnce || skip) return;
122+
123+
setState({ inView: !!initialInView, entry: undefined });
144124
lastInViewRef.current = initialInView;
145-
}
125+
});
146126

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

0 commit comments

Comments
 (0)