Skip to content

Commit 77f4711

Browse files
authored
fix: Handle multiple intersection changes in a single callback (#3747)
1 parent 8d13105 commit 77f4711

File tree

2 files changed

+93
-26
lines changed

2 files changed

+93
-26
lines changed
Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,111 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
import React from 'react';
4-
import { render } from '@testing-library/react';
4+
import { act, cleanup, render } from '@testing-library/react';
55

6-
import { useIntersectionObserver } from '..';
7-
8-
declare global {
9-
interface Window {
10-
IntersectionObserver: any;
11-
}
12-
}
6+
import { useIntersectionObserver } from '../../../../../lib/components/internal/hooks/use-intersection-observer';
137

148
function TestComponent({ initialState }: { initialState?: boolean }) {
159
const { ref, isIntersecting } = useIntersectionObserver({ initialState });
1610
return <div ref={ref} data-testid="test" data-value={isIntersecting} />;
1711
}
1812

19-
const mockObserve = jest.fn();
20-
const mockIntersectionObserver = jest.fn(() => ({
21-
observe: mockObserve,
22-
disconnect: jest.fn(),
23-
}));
13+
type MockIntersectionObserverCallback = (
14+
// only fields actually used in our implementation
15+
entries: Array<Pick<IntersectionObserverEntry, 'isIntersecting' | 'time'>>
16+
) => void;
17+
let mockObserverInstance: (IntersectionObserver & { callback: MockIntersectionObserverCallback }) | undefined;
18+
19+
class MockIntersectionObserver {
20+
constructor(callback: MockIntersectionObserverCallback) {
21+
if (mockObserverInstance) {
22+
throw new Error('only one observer instance expected per test');
23+
}
24+
const instance = {
25+
observe: jest.fn() as IntersectionObserver['observe'],
26+
disconnect: jest.fn() as IntersectionObserver['disconnect'],
27+
} as IntersectionObserver;
28+
mockObserverInstance = {
29+
...instance,
30+
callback,
31+
};
32+
return instance;
33+
}
34+
}
2435

2536
describe('useIntersectionObserver', () => {
2637
beforeEach(() => {
27-
window.IntersectionObserver = mockIntersectionObserver;
38+
window.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;
2839
jest.clearAllMocks();
2940
});
3041

31-
it('should create an observer with the defined target element', async () => {
32-
const { findByTestId } = render(<TestComponent />);
33-
const target = await findByTestId('test');
42+
afterEach(() => {
43+
cleanup();
44+
expect(mockObserverInstance!.disconnect).toHaveBeenCalled();
45+
mockObserverInstance = undefined;
46+
});
47+
48+
it('should create an observer with the defined target element', () => {
49+
const { getByTestId } = render(<TestComponent />);
50+
const target = getByTestId('test');
51+
52+
expect(mockObserverInstance!.observe).toHaveBeenCalledWith(target);
53+
});
54+
55+
it('defaults to not intersecting', () => {
56+
const { getByTestId } = render(<TestComponent />);
57+
const target = getByTestId('test');
58+
59+
expect(target.dataset.value).toBe('false');
60+
});
61+
62+
it('allows overriding the default value', () => {
63+
const { getByTestId } = render(<TestComponent initialState={true} />);
64+
const target = getByTestId('test');
3465

35-
expect(mockIntersectionObserver).toHaveBeenCalled();
36-
expect(mockObserve).toHaveBeenCalledWith(target);
66+
expect(target.dataset.value).toBe('true');
3767
});
3868

39-
it('defaults to not intersecting', async () => {
40-
const { findByTestId } = render(<TestComponent />);
41-
const target = await findByTestId('test');
69+
it('updates value with a callback', () => {
70+
const { getByTestId } = render(<TestComponent />);
71+
72+
const target = getByTestId('test');
73+
expect(target.dataset.value).toBe('false');
74+
75+
act(() => mockObserverInstance!.callback([{ time: 0, isIntersecting: true }]));
76+
expect(target.dataset.value).toBe('true');
4277

78+
act(() => mockObserverInstance!.callback([{ time: 0, isIntersecting: false }]));
4379
expect(target.dataset.value).toBe('false');
4480
});
4581

46-
it('allows overriding the default value', async () => {
47-
const { findByTestId } = render(<TestComponent initialState={true} />);
48-
const target = await findByTestId('test');
82+
it('should resolve the final state if multiple observations occurred', () => {
83+
const { getByTestId } = render(<TestComponent />);
84+
85+
act(() =>
86+
mockObserverInstance!.callback([
87+
{ time: 0, isIntersecting: true },
88+
{ time: 1, isIntersecting: false },
89+
{ time: 2, isIntersecting: true },
90+
])
91+
);
92+
93+
const target = getByTestId('test');
94+
expect(target.dataset.value).toBe('true');
95+
});
96+
97+
it('should sort observations by time', () => {
98+
const { getByTestId } = render(<TestComponent />);
99+
100+
act(() =>
101+
mockObserverInstance!.callback([
102+
{ time: 1, isIntersecting: false },
103+
{ time: 2, isIntersecting: true },
104+
{ time: 0, isIntersecting: false },
105+
])
106+
);
49107

108+
const target = getByTestId('test');
50109
expect(target.dataset.value).toBe('true');
51110
});
52111
});

src/internal/hooks/use-intersection-observer/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@ export function useIntersectionObserver<T extends HTMLElement>({
4141
} catch {
4242
// Tried to access a cross-origin iframe. Fall back to current IntersectionObserver.
4343
}
44-
observerRef.current = new TopLevelIntersectionObserver(([entry]) => setIsIntersecting(entry.isIntersecting));
44+
observerRef.current = new TopLevelIntersectionObserver(entries => {
45+
let latestEntry = entries[0];
46+
for (const entry of entries) {
47+
if (entry.time > latestEntry.time) {
48+
latestEntry = entry;
49+
}
50+
}
51+
setIsIntersecting(latestEntry.isIntersecting);
52+
});
4553
observerRef.current.observe(targetElement);
4654
}
4755
}, []);

0 commit comments

Comments
 (0)