Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use of React 19 ref callbacks for IntersectionObserver tracking #718

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,62 @@ You can read more about this on these links:
- [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372)
- [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465)

### `useOnInView` hook

```js
const inViewRef = useOnInView(
(enterEntry) => {
// Do something with the element that came into view
console.log('Element is in view', enterEntry?.target);

// Optionally return a cleanup function
return (exitEntry) => {
console.log('Element moved out of view or unmounted');
};
},
options // Optional IntersectionObserver options
);
```

The `useOnInView` hook provides a more direct alternative to `useInView`. It takes a callback function and returns a ref that you can assign to the DOM element you want to monitor. When the element enters the viewport, your callback will be triggered.

Key differences from `useInView`:
- **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios
- **Direct element access** - Your callback receives the actual IntersectionObserverEntry with the `target` element
- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport
- **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange` and `initialInView`

The `trigger` option allows to listen for the element entering the viewport or leaving the viewport. The default is `enter`.

```jsx
import React from "react";
import { useOnInView } from "react-intersection-observer";

const Component = () => {
// Track when element appears without causing re-renders
const trackingRef = useOnInView((entry) => {
// Element is in view - perhaps log an impression
console.log("Element appeared in view", entry.target);

// Return optional cleanup function
return () => {
console.log("Element left view");
};
}, {
/* Optional options */
threshold: 0.5,
trigger: "enter",
triggerOnce: true,
});

return (
<div ref={trackingRef}>
<h2>This element is being tracked without re-renders</h2>
</div>
);
};
```

## Testing

> [!TIP]
Expand Down
3 changes: 3 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
}
],
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^19.0.0",
"react-dom": "^19.0.0"
Comment on lines +111 to +112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @thebuilder @jantimon, thanks for being careful about supporting this without breaking React 18, it's critical for several projects we're working on.

Some projects can't upgrade to React 19 anytime soon due to legacy dependencies that may never support it. Since React 19 is still recent, many packages lack support, so upgrading isn't an option yet.

If React 19 becomes the only target, a major version bump would likely be needed to avoid breaking existing setups.

Appreciate the careful consideration!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the input. And I agree - We shouldn't just break React 18. I supported React 15 and 16 until last year.

},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.4",
Expand Down
25 changes: 6 additions & 19 deletions src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,29 +103,17 @@ export class InView extends React.Component<

observeNode() {
if (!this.node || this.props.skip) return;
const {
const { threshold, root, rootMargin, trackVisibility, delay } = this.props;

this._unobserveCb = observe(this.node, this.handleChange, {
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
fallbackInView,
} = this.props;

this._unobserveCb = observe(
this.node,
this.handleChange,
{
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
},
fallbackInView,
);
});
}

unobserve() {
Expand Down Expand Up @@ -184,7 +172,6 @@ export class InView extends React.Component<
trackVisibility,
delay,
initialInView,
fallbackInView,
...props
} = this.props as PlainChildrenProps;

Expand Down
60 changes: 0 additions & 60 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
import { userEvent } from "@vitest/browser/context";
import React from "react";
import { InView } from "../InView";
import { defaultFallbackInView } from "../observe";
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";

test("Should render <InView /> intersecting", () => {
Expand Down Expand Up @@ -157,62 +156,3 @@ test("plain children should not catch bubbling onChange event", async () => {
await userEvent.type(input, "changed value");
expect(onChange).not.toHaveBeenCalled();
});

test("should render with fallback", () => {
const cb = vi.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
render(
<InView fallbackInView={true} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

render(
<InView fallbackInView={false} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrow();
});

test("should render with global fallback", () => {
const cb = vi.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

defaultFallbackInView(false);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

defaultFallbackInView(undefined);
expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrow();
});
56 changes: 4 additions & 52 deletions src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import React, { useCallback } from "react";
import { type IntersectionOptions, defaultFallbackInView } from "../index";
import type { IntersectionOptions } from "../index";
import {
destroyIntersectionMocking,
intersectionMockInstance,
Expand Down Expand Up @@ -235,9 +235,7 @@ test("should handle ref removed", () => {
const MergeRefsComponent = ({ options }: { options?: IntersectionOptions }) => {
const [inViewRef, inView] = useInView(options);
const setRef = useCallback(
(node: Element | null) => {
inViewRef(node);
},
(node: Element | null) => inViewRef(node),
[inViewRef],
);

Expand All @@ -263,9 +261,8 @@ const MultipleHookComponent = ({

const mergedRefs = useCallback(
(node: Element | null) => {
ref1(node);
ref2(node);
ref3(node);
const cleanup = [ref1(node), ref2(node), ref3(node)];
return () => cleanup.forEach((fn) => fn());
},
[ref1, ref2, ref3],
);
Expand Down Expand Up @@ -342,51 +339,6 @@ test("should set intersection ratio as the largest threshold smaller than trigge
screen.getByText(/intersectionRatio: 0.5/);
});

test("should handle fallback if unsupported", () => {
destroyIntersectionMocking();
// @ts-ignore
window.IntersectionObserver = undefined;
const { rerender } = render(
<HookComponent options={{ fallbackInView: true }} />,
);
screen.getByText("true");

rerender(<HookComponent options={{ fallbackInView: false }} />);
screen.getByText("false");

expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
rerender(<HookComponent options={{ fallbackInView: undefined }} />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`[TypeError: IntersectionObserver is not a constructor]`,
);
});

test("should handle defaultFallbackInView if unsupported", () => {
destroyIntersectionMocking();
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
const { rerender } = render(<HookComponent key="true" />);
screen.getByText("true");

defaultFallbackInView(false);
rerender(<HookComponent key="false" />);
screen.getByText("false");

defaultFallbackInView(undefined);
expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
rerender(<HookComponent key="undefined" />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`[TypeError: IntersectionObserver is not a constructor]`,
);
});

test("should restore the browser IntersectionObserver", () => {
expect(vi.isMockFunction(window.IntersectionObserver)).toBe(true);
destroyIntersectionMocking();
Expand Down
Loading