-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react-dom): add InView, useInView (#1184)
related with #1071 # Overview <!-- A clear and concise description of what this pr is about. --> ## PR Checklist - [x] I did below actions if need 1. I read the [Contributing Guide](https://github.com/toss/suspensive/blob/main/CONTRIBUTING.md) 2. I added documents and tests. --------- Co-authored-by: Hyeonjae Lee <71202076+2-NOW@users.noreply.github.com> Co-authored-by: Jun <26808056+juno7803@users.noreply.github.com> Co-authored-by: GwanSik Kim <39869096+gwansikk@users.noreply.github.com> Co-authored-by: 김동희 <31029000+bigsaigon333@users.noreply.github.com> Co-authored-by: Juhyeok Kang <23312485+kangju2000@users.noreply.github.com> Co-authored-by: 김석진(poki) <102217654+SEOKKAMONI@users.noreply.github.com>
- Loading branch information
1 parent
da590b7
commit cc053a2
Showing
21 changed files
with
1,039 additions
and
153 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
'use client' | ||
|
||
import { InView } from '@suspensive/react-dom' | ||
|
||
export default function Page() { | ||
return ( | ||
<div> | ||
{Array.from({ length: 200 }).map((_, i) => ( | ||
// eslint-disable-next-line @eslint-react/no-duplicate-key | ||
<InView key={i} threshold={0.8} delay={200} triggerOnce initialInView> | ||
{({ inView, ref }) => ( | ||
<div ref={ref}> | ||
{inView ? ( | ||
<div className="mt-2 h-14 w-96 bg-white" /> | ||
) : ( | ||
<div className="mt-2 h-14 w-96 animate-pulse bg-[#ffffff80]" /> | ||
)} | ||
</div> | ||
)} | ||
</InView> | ||
))} | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { render, screen } from '@testing-library/react' | ||
import { InView } from './InView' | ||
import { mockAllIsIntersecting } from './test-utils' | ||
|
||
describe('<InView/>', () => { | ||
it('should render <InView /> intersecting', () => { | ||
const callback = vi.fn() | ||
render(<InView onChange={callback}>{({ inView, ref }) => <div ref={ref}>{inView.toString()}</div>}</InView>) | ||
|
||
mockAllIsIntersecting(false) | ||
expect(callback).toHaveBeenLastCalledWith(false, expect.objectContaining({ isIntersecting: false })) | ||
|
||
mockAllIsIntersecting(true) | ||
expect(callback).toHaveBeenLastCalledWith(true, expect.objectContaining({ isIntersecting: true })) | ||
}) | ||
|
||
// eslint-disable-next-line vitest/expect-expect | ||
it('should handle initialInView', () => { | ||
const cb = vi.fn() | ||
render( | ||
<InView initialInView onChange={cb}> | ||
{({ inView }) => <span>InView: {inView.toString()}</span>} | ||
</InView> | ||
) | ||
screen.getByText('InView: true') | ||
}) | ||
|
||
// eslint-disable-next-line vitest/expect-expect | ||
it('should unobserve old node', () => { | ||
const { rerender } = render( | ||
<InView> | ||
{({ inView, ref }) => ( | ||
<div key="1" ref={ref}> | ||
Inview: {inView.toString()} | ||
</div> | ||
)} | ||
</InView> | ||
) | ||
rerender( | ||
<InView> | ||
{({ inView, ref }) => ( | ||
<div key="2" ref={ref}> | ||
Inview: {inView.toString()} | ||
</div> | ||
)} | ||
</InView> | ||
) | ||
mockAllIsIntersecting(true) | ||
}) | ||
|
||
// eslint-disable-next-line vitest/expect-expect | ||
it('should ensure node exists before observing and unobserving', () => { | ||
const { unmount } = render(<InView>{() => null}</InView>) | ||
unmount() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { type InViewOptions, useInView } from './useInView' | ||
|
||
interface InViewProps extends InViewOptions { | ||
children: (inViewResult: ReturnType<typeof useInView>) => React.ReactNode | ||
} | ||
|
||
export function InView({ children, ...options }: InViewProps) { | ||
return <>{children(useInView(options))}</> | ||
} |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { cleanup, render, screen } from '@testing-library/react/pure' | ||
import { type InViewOptions, useInView } from '../useInView' | ||
|
||
afterEach(() => { | ||
cleanup() | ||
}) | ||
|
||
const HookComponent = ({ options }: { options?: InViewOptions }) => { | ||
const div = useInView(options) | ||
|
||
return ( | ||
<div ref={div.ref} data-testid="wrapper" style={{ height: 200, background: 'cyan' }} data-inview={div.inView}> | ||
InView block | ||
</div> | ||
) | ||
} | ||
|
||
test('should come into view on after rendering', async () => { | ||
render(<HookComponent />) | ||
const wrapper = screen.getByTestId('wrapper') | ||
await expect.element(wrapper).toHaveAttribute('data-inview', 'true') | ||
}) | ||
|
||
test('should come into view after scrolling', async () => { | ||
render( | ||
<> | ||
<div style={{ height: window.innerHeight }} /> | ||
<HookComponent /> | ||
<div style={{ height: window.innerHeight }} /> | ||
</> | ||
) | ||
const wrapper = screen.getByTestId('wrapper') | ||
|
||
// Should not be inside the view | ||
expect(wrapper).toHaveAttribute('data-inview', 'false') | ||
|
||
// Scroll so the element comes into view | ||
window.scrollTo(0, window.innerHeight) | ||
// Should not be updated until intersection observer triggers | ||
expect(wrapper).toHaveAttribute('data-inview', 'false') | ||
|
||
await expect.element(wrapper).toHaveAttribute('data-inview', 'true') | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export { TestText } from './TestText' | ||
export { InView } from './InView' | ||
export { useInView } from './useInView' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
import * as React from 'react' | ||
import * as DeprecatedReactTestUtils from 'react-dom/test-utils' | ||
|
||
declare global { | ||
// eslint-disable-next-line no-var | ||
var IS_REACT_ACT_ENVIRONMENT: boolean | ||
// eslint-disable-next-line no-var | ||
var jest: { fn: typeof vi.fn } | undefined | ||
} | ||
|
||
const act = typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act | ||
|
||
type Item = { | ||
callback: IntersectionObserverCallback | ||
elements: Set<Element> | ||
created: number | ||
} | ||
|
||
let isMocking = false | ||
|
||
const observers = new Map<IntersectionObserver, Item>() | ||
|
||
// If we are running in a valid testing environment, we can mock the IntersectionObserver. | ||
if (typeof beforeAll !== 'undefined' && typeof afterEach !== 'undefined') { | ||
beforeAll(() => { | ||
// Use the exposed mock function. Currently, only supports Jest (`jest.fn`) and Vitest with globals (`vi.fn`). | ||
if (typeof jest !== 'undefined') setupIntersectionMocking(jest.fn) | ||
else if (typeof vi !== 'undefined') { | ||
setupIntersectionMocking(vi.fn) | ||
} | ||
}) | ||
|
||
afterEach(() => { | ||
resetIntersectionMocking() | ||
}) | ||
} | ||
|
||
function warnOnMissingSetup() { | ||
if (isMocking) return | ||
console.error( | ||
`React Intersection Observer was not configured to handle mocking. | ||
Outside Jest and Vitest, you might need to manually configure it by calling setupIntersectionMocking() and resetIntersectionMocking() in your test setup file. | ||
// test-setup.js | ||
import { resetIntersectionMocking, setupIntersectionMocking } from 'react-intersection-observer/test-utils'; | ||
beforeEach(() => { | ||
setupIntersectionMocking(vi.fn); | ||
}); | ||
afterEach(() => { | ||
resetIntersectionMocking(); | ||
});` | ||
) | ||
} | ||
|
||
function setupIntersectionMocking(mockFn: typeof vi.fn) { | ||
global.IntersectionObserver = mockFn((cb, options = {}) => { | ||
const item = { | ||
callback: cb, | ||
elements: new Set<Element>(), | ||
created: Date.now(), | ||
} | ||
const instance: IntersectionObserver = { | ||
thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0], | ||
root: options.root ?? null, | ||
rootMargin: options.rootMargin ?? '', | ||
observe: mockFn((element: Element) => { | ||
item.elements.add(element) | ||
}), | ||
unobserve: mockFn((element: Element) => { | ||
item.elements.delete(element) | ||
}), | ||
disconnect: mockFn(() => { | ||
observers.delete(instance) | ||
}), | ||
takeRecords: mockFn(), | ||
} | ||
|
||
observers.set(instance, item) | ||
|
||
return instance | ||
}) | ||
|
||
isMocking = true | ||
} | ||
|
||
function resetIntersectionMocking() { | ||
if ( | ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
global.IntersectionObserver && | ||
'mockClear' in global.IntersectionObserver && | ||
typeof global.IntersectionObserver.mockClear === 'function' | ||
) { | ||
global.IntersectionObserver.mockClear() | ||
} | ||
observers.clear() | ||
} | ||
|
||
function triggerIntersection( | ||
elements: Element[], | ||
trigger: boolean | number, | ||
observer: IntersectionObserver, | ||
item: Item | ||
) { | ||
const entries: IntersectionObserverEntry[] = [] | ||
|
||
const isIntersecting = | ||
typeof trigger === 'number' ? observer.thresholds.some((threshold) => trigger >= threshold) : trigger | ||
|
||
let ratio: number | ||
|
||
if (typeof trigger === 'number') { | ||
const intersectedThresholds = observer.thresholds.filter((threshold) => trigger >= threshold) | ||
ratio = intersectedThresholds.length > 0 ? intersectedThresholds[intersectedThresholds.length - 1] : 0 | ||
} else { | ||
ratio = trigger ? 1 : 0 | ||
} | ||
|
||
for (const element of elements) { | ||
entries.push(<IntersectionObserverEntry>{ | ||
boundingClientRect: element.getBoundingClientRect(), | ||
intersectionRatio: ratio, | ||
intersectionRect: isIntersecting | ||
? element.getBoundingClientRect() | ||
: { | ||
bottom: 0, | ||
height: 0, | ||
left: 0, | ||
right: 0, | ||
top: 0, | ||
width: 0, | ||
x: 0, | ||
y: 0, | ||
toJSON() {}, | ||
}, | ||
isIntersecting, | ||
rootBounds: observer.root instanceof Element ? observer.root.getBoundingClientRect() : null, | ||
target: element, | ||
time: Date.now() - item.created, | ||
}) | ||
} | ||
|
||
// Trigger the IntersectionObserver callback with all the entries | ||
if ( | ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
act && | ||
Boolean(global.IS_REACT_ACT_ENVIRONMENT) | ||
) | ||
act(() => item.callback(entries, observer)) | ||
else item.callback(entries, observer) | ||
} | ||
|
||
export function mockAllIsIntersecting(isIntersecting: boolean | number) { | ||
warnOnMissingSetup() | ||
for (const [observer, item] of observers) { | ||
triggerIntersection(Array.from(item.elements), isIntersecting, observer, item) | ||
} | ||
} | ||
|
||
export function mockIsIntersecting(element: Element, isIntersecting: boolean | number) { | ||
warnOnMissingSetup() | ||
const observer = intersectionMockInstance(element) | ||
if ( | ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
!observer | ||
) { | ||
throw new Error('No IntersectionObserver instance found for element. Is it still mounted in the DOM?') | ||
} | ||
const item = observers.get(observer) | ||
if (item) { | ||
triggerIntersection([element], isIntersecting, observer, item) | ||
} | ||
} | ||
|
||
export function intersectionMockInstance(element: Element): IntersectionObserver { | ||
warnOnMissingSetup() | ||
for (const [observer, item] of observers) { | ||
if (item.elements.has(element)) { | ||
return observer | ||
} | ||
} | ||
|
||
throw new Error('Failed to find IntersectionObserver for element. Is it being observed?') | ||
} |
Oops, something went wrong.