Skip to content

use of React 19 ref callbacks for IntersectionObserver tracking #718

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

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
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
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,17 @@
}
],
"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.2",
"@biomejs/biome": "^1.9.4",
"@size-limit/preset-small-lib": "^11.1.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^2.1.8",
"@vitest/coverage-istanbul": "^2.1.8",
Expand Down
32 changes: 24 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
9 changes: 4 additions & 5 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import type * as React from "react";
export { InView } from "./InView";
export { useInView } from "./useInView";
export { observe, defaultFallbackInView } from "./observe";
export { useOnInViewChanged } from "./useOnInViewChanged";
export { observe } from "./observe";

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

Expand Down Expand Up @@ -32,8 +33,6 @@ export interface IntersectionOptions extends IntersectionObserverInit {
skip?: boolean;
/** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */
initialInView?: boolean;
/** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */
fallbackInView?: boolean;
/** IntersectionObserver v2 - Track the actual visibility of the element */
trackVisibility?: boolean;
/** IntersectionObserver v2 - Set a minimum delay between notifications */
Expand Down Expand Up @@ -74,11 +73,11 @@ export type PlainChildrenProps = IntersectionOptions & {
* The Hook response supports both array and object destructing
*/
export type InViewHookResponse = [
(node?: Element | null) => void,
(node?: Element | null) => () => void,
boolean,
IntersectionObserverEntry | undefined,
] & {
ref: (node?: Element | null) => void;
ref: (node?: Element | null) => () => void;
inView: boolean;
entry?: IntersectionObserverEntry;
};
33 changes: 0 additions & 33 deletions src/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@ const observerMap = new Map<
const RootIds: WeakMap<Element | Document, string> = new WeakMap();
let rootId = 0;

let unsupportedValue: boolean | undefined = undefined;

/**
* What should be the default behavior if the IntersectionObserver is unsupported?
* Ideally the polyfill has been loaded, you can have the following happen:
* - `undefined`: Throw an error
* - `true` or `false`: Set the `inView` value to this regardless of intersection state
* **/
export function defaultFallbackInView(inView: boolean | undefined) {
unsupportedValue = inView;
}

/**
* Generate a unique ID for the root element
* @param root
Expand Down Expand Up @@ -112,34 +100,13 @@ function createObserver(options: IntersectionObserverInit) {
* @param element - DOM Element to observe
* @param callback - Callback function to trigger when intersection status changes
* @param options - Intersection Observer options
* @param fallbackInView - Fallback inView value.
* @return Function - Cleanup function that should be triggered to unregister the observer
*/
export function observe(
element: Element,
callback: ObserverInstanceCallback,
options: IntersectionObserverInit = {},
fallbackInView = unsupportedValue,
) {
if (
typeof window.IntersectionObserver === "undefined" &&
fallbackInView !== undefined
) {
const bounds = element.getBoundingClientRect();
callback(fallbackInView, {
isIntersecting: fallbackInView,
target: element,
intersectionRatio:
typeof options.threshold === "number" ? options.threshold : 0,
time: 0,
boundingClientRect: bounds,
intersectionRect: bounds,
rootBounds: bounds,
});
return () => {
// Nothing to cleanup
};
}
// An observer with the same options can be reused, so lets use this fact
const { id, observer, elements } = createObserver(options);

Expand Down
Loading
Loading