Skip to content

Commit 6b035cf

Browse files
authored
feat(useIntersectionObserver): allow lazy roots (#53)
BREAKING CHANGE: `null` in the native API means "the window", this is a departure to allow a consumer to hold off setting up the observer until the have a ref to the root. This was possible before by explicitly setting element to `null` until the root is available but still created an extra observer
1 parent 0242e0d commit 6b035cf

File tree

4 files changed

+164
-5
lines changed

4 files changed

+164
-5
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@babel/cli": "^7.12.10",
5858
"@babel/core": "^7.12.10",
5959
"@babel/preset-typescript": "^7.12.7",
60+
"@testing-library/react-hooks": "^7.0.0",
6061
"@types/enzyme": "^3.10.8",
6162
"@types/jest": "^26.0.19",
6263
"@types/lodash": "^4.14.167",

src/useIntersectionObserver.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import useEventCallback from './useEventCallback'
99
* a DOM Element that returns it's entries as they arrive.
1010
*
1111
* @param element The DOM element to observe
12-
* @param init IntersectionObserver options
12+
* @param init IntersectionObserver options with a notable change,
13+
* unlike a plain IntersectionObserver `root: null` means "not provided YET",
14+
* and the hook will wait until it receives a non-null value to set up the observer.
15+
* This change allows for easier syncing of element and root values in a React
16+
* context.
1317
*/
1418
function useIntersectionObserver<TElement extends Element>(
1519
element: TElement | null | undefined,
16-
options: IntersectionObserverInit,
20+
options?: IntersectionObserverInit,
1721
): IntersectionObserverEntry[]
1822
/**
1923
* Setup an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) on
@@ -22,16 +26,21 @@ function useIntersectionObserver<TElement extends Element>(
2226
*
2327
* @param element The DOM element to observe
2428
* @param callback A listener for intersection updates.
25-
* @param init IntersectionObserver options
29+
* @param init IntersectionObserver options with a notable change,
30+
* unlike a plain IntersectionObserver `root: null` means "not provided YET",
31+
* and the hook will wait until it receives a non-null value to set up the observer.
32+
* This change allows for easier syncing of element and root values in a React
33+
* context.
34+
*
2635
*/
2736
function useIntersectionObserver<TElement extends Element>(
2837
element: TElement | null | undefined,
2938
callback: IntersectionObserverCallback,
30-
options: IntersectionObserverInit,
39+
options?: IntersectionObserverInit,
3140
): void
3241
function useIntersectionObserver<TElement extends Element>(
3342
element: TElement | null | undefined,
34-
callbackOrOptions: IntersectionObserverCallback | IntersectionObserverInit,
43+
callbackOrOptions?: IntersectionObserverCallback | IntersectionObserverInit,
3544
maybeOptions?: IntersectionObserverInit,
3645
): void | IntersectionObserverEntry[] {
3746
let callback: IntersectionObserverCallback | undefined
@@ -47,8 +56,10 @@ function useIntersectionObserver<TElement extends Element>(
4756

4857
const handler = useEventCallback(callback || setEntry)
4958

59+
// We wait for element to exist before constructing
5060
const observer = useStableMemo(
5161
() =>
62+
root !== null &&
5263
typeof IntersectionObserver !== 'undefined' &&
5364
new IntersectionObserver(handler, {
5465
threshold,

test/useIntersectionObserver.test.tsx

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import useIntersectionObserver from '../src/useIntersectionObserver'
2+
import useCallbackRef from '../src/useCallbackRef'
3+
import React from 'react'
4+
import { renderHook, act } from '@testing-library/react-hooks'
5+
6+
describe('useIntersectionObserver', () => {
7+
let observers: any[] = []
8+
beforeEach(() => {
9+
;(window as any).IntersectionObserver = class IntersectionObserverMock {
10+
observe: jest.Mock<any, any>
11+
unobserve: jest.Mock<any, any>
12+
args: [IntersectionObserverCallback, IntersectionObserverEntryInit]
13+
constructor(handler: any, init: any) {
14+
this.args = [handler, init]
15+
this.observe = jest.fn()
16+
this.unobserve = jest.fn()
17+
observers.push(this)
18+
}
19+
}
20+
})
21+
22+
afterEach(() => {
23+
observers = []
24+
})
25+
26+
it('should observe element', async () => {
27+
const element = document.createElement('span')
28+
29+
const { result } = renderHook(() => useIntersectionObserver(element))
30+
const entry = {}
31+
expect(result.current).toEqual([])
32+
33+
act(() => {
34+
observers[0].args[0]([entry])
35+
})
36+
37+
expect(result.current[0]).toStrictEqual(entry)
38+
})
39+
40+
it('should wait for element', async () => {
41+
const element = document.createElement('span')
42+
43+
const { result, rerender, unmount } = renderHook(
44+
({ element }) => useIntersectionObserver(element),
45+
{ initialProps: { element: null as any } },
46+
)
47+
48+
expect(result.current).toEqual([])
49+
50+
expect(observers[0].observe).not.toBeCalled()
51+
52+
rerender({ element })
53+
54+
expect(observers[0].observe).toBeCalledTimes(1)
55+
56+
unmount()
57+
58+
expect(observers[0].unobserve).toBeCalledTimes(1)
59+
})
60+
61+
it('should wait for root to set up observer', async () => {
62+
const root = document.createElement('div')
63+
const element = document.createElement('span')
64+
65+
const { result, rerender } = renderHook(
66+
(root: any) => useIntersectionObserver(element, { root }),
67+
{ initialProps: null },
68+
)
69+
70+
expect(observers).toHaveLength(0)
71+
72+
rerender(root)
73+
74+
expect(observers).toHaveLength(1)
75+
expect(observers[0].observe).toBeCalledTimes(1)
76+
})
77+
78+
it('should accept a callback', async () => {
79+
const spy = jest.fn()
80+
const element = document.createElement('span')
81+
82+
const { result } = renderHook(() => useIntersectionObserver(element, spy))
83+
84+
expect(result.current).toEqual(undefined)
85+
86+
const entry = {}
87+
act(() => {
88+
observers[0].args[0]([entry, observers[0]])
89+
})
90+
91+
expect(spy).toBeCalledTimes(1)
92+
expect(spy).toHaveBeenLastCalledWith([entry, observers[0]])
93+
})
94+
})

yarn.lock

+53
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,13 @@
10931093
core-js-pure "^3.0.0"
10941094
regenerator-runtime "^0.13.4"
10951095

1096+
"@babel/runtime@^7.12.5":
1097+
version "7.14.6"
1098+
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d"
1099+
integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
1100+
dependencies:
1101+
regenerator-runtime "^0.13.4"
1102+
10961103
"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4":
10971104
version "7.13.9"
10981105
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.9.tgz#97dbe2116e2630c489f22e0656decd60aaa1fcee"
@@ -1472,6 +1479,17 @@
14721479
dependencies:
14731480
"@sinonjs/commons" "^1.7.0"
14741481

1482+
"@testing-library/react-hooks@^7.0.0":
1483+
version "7.0.0"
1484+
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.0.tgz#dd6d37a7e018f147a3b9153137f10e013be8472b"
1485+
integrity sha512-WFBGH8DWdIGGBHt6PBtQPe2v4Kbj9vQ1sQ9qLBTmwn1PNggngint4MTE/IiWCYhPbyTW3oc/7X62DObMn/AjQQ==
1486+
dependencies:
1487+
"@babel/runtime" "^7.12.5"
1488+
"@types/react" ">=16.9.0"
1489+
"@types/react-dom" ">=16.9.0"
1490+
"@types/react-test-renderer" ">=16.9.0"
1491+
react-error-boundary "^3.1.0"
1492+
14751493
"@tootallnate/once@1":
14761494
version "1.1.2"
14771495
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@@ -1637,6 +1655,20 @@
16371655
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
16381656
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
16391657

1658+
"@types/react-dom@>=16.9.0":
1659+
version "17.0.7"
1660+
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.7.tgz#b8ee15ead9e5d6c2c858b44949fdf2ebe5212232"
1661+
integrity sha512-Wd5xvZRlccOrCTej8jZkoFZuZRKHzanDDv1xglI33oBNFMWrqOSzrvWFw7ngSiZjrpJAzPKFtX7JvuXpkNmQHA==
1662+
dependencies:
1663+
"@types/react" "*"
1664+
1665+
"@types/react-test-renderer@>=16.9.0":
1666+
version "17.0.1"
1667+
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b"
1668+
integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==
1669+
dependencies:
1670+
"@types/react" "*"
1671+
16401672
"@types/react@*", "@types/react@^17.0.0":
16411673
version "17.0.2"
16421674
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.2.tgz#3de24c4efef902dd9795a49c75f760cbe4f7a5a8"
@@ -1645,6 +1677,20 @@
16451677
"@types/prop-types" "*"
16461678
csstype "^3.0.2"
16471679

1680+
"@types/react@>=16.9.0":
1681+
version "17.0.11"
1682+
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
1683+
integrity sha512-yFRQbD+whVonItSk7ZzP/L+gPTJVBkL/7shLEF+i9GC/1cV3JmUxEQz6+9ylhUpWSDuqo1N9qEvqS6vTj4USUA==
1684+
dependencies:
1685+
"@types/prop-types" "*"
1686+
"@types/scheduler" "*"
1687+
csstype "^3.0.2"
1688+
1689+
"@types/scheduler@*":
1690+
version "0.16.1"
1691+
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275"
1692+
integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==
1693+
16481694
"@types/schema-utils@^2.4.0":
16491695
version "2.4.0"
16501696
resolved "https://registry.yarnpkg.com/@types/schema-utils/-/schema-utils-2.4.0.tgz#9983012045d541dcee053e685a27c9c87c840fcd"
@@ -7915,6 +7961,13 @@ react-dom@^16.13.0:
79157961
prop-types "^15.6.2"
79167962
scheduler "^0.19.1"
79177963

7964+
react-error-boundary@^3.1.0:
7965+
version "3.1.3"
7966+
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b"
7967+
integrity sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==
7968+
dependencies:
7969+
"@babel/runtime" "^7.12.5"
7970+
79187971
react-error-overlay@^6.0.9:
79197972
version "6.0.9"
79207973
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"

0 commit comments

Comments
 (0)