Skip to content

Commit

Permalink
feat(react-dom): add InView, useInView (#1184)
Browse files Browse the repository at this point in the history
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
7 people authored Sep 4, 2024
1 parent da590b7 commit cc053a2
Show file tree
Hide file tree
Showing 21 changed files with 1,039 additions and 153 deletions.
5 changes: 1 addition & 4 deletions configs/eslint-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,7 @@ export const suspensiveTypeScriptConfig: ReturnType<typeof tseslint.config> = ts
{
files: ['**/*.spec.ts*', '**/*.test.ts*', '**/*.test-d.ts*'],
plugins: { vitest },
rules: {
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'warn',
},
rules: vitest.configs.recommended.rules,
settings: { vitest: { typecheck: true } },
},
jestDom.configs['flat/recommended'] as unknown as ReturnType<typeof tseslint.config>[number],

Check warning on line 91 in configs/eslint-config/src/index.ts

View workflow job for this annotation

GitHub Actions / Check quality (ci:eslint)

Unsafe member access .configs on an `error` typed value
Expand Down
1 change: 1 addition & 0 deletions examples/visualization/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@suspensive/cache": "workspace:*",
"@suspensive/react": "workspace:*",
"@suspensive/react-dom": "workspace:*",
"@suspensive/react-image": "workspace:*",
"@suspensive/react-query": "workspace:*",
"@tanstack/react-query": "catalog:react-query4",
Expand Down
8 changes: 8 additions & 0 deletions examples/visualization/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</li>
</details>
</li>
<li>
<details>
<summary>@suspensive/react-dom</summary>
<li>
<Link href="/react-dom/InView">{`<InView/>`}</Link>
</li>
</details>
</li>
<li>
<details>
<summary>@suspensive/react-query</summary>
Expand Down
24 changes: 24 additions & 0 deletions examples/visualization/src/app/react-dom/InView/page.tsx
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>
)
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@types/node": "^20.14.13",
"@vitest/browser": "^2.0.5",
"@vitest/coverage-istanbul": "^2.0.5",
"@vitest/coverage-v8": "^2.0.5",
"@vitest/ui": "^2.0.5",
"broken-link-checker": "^0.7.8",
"eslint": "^9.9.1",
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
"@suspensive/tsconfig": "workspace:*",
"@suspensive/tsup": "workspace:*",
"@types/react": "catalog:react18",
"react": "catalog:react18"
"@types/react-dom": "catalog:react18",
"react": "catalog:react18",
"react-dom": "catalog:react18"
},
"peerDependencies": {
"react": "^18"
Expand Down
56 changes: 56 additions & 0 deletions packages/react-dom/src/InView.spec.tsx
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()
})
})
9 changes: 9 additions & 0 deletions packages/react-dom/src/InView.tsx
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))}</>
}
11 changes: 0 additions & 11 deletions packages/react-dom/src/TestText.spec.tsx

This file was deleted.

1 change: 0 additions & 1 deletion packages/react-dom/src/TestText.tsx

This file was deleted.

43 changes: 43 additions & 0 deletions packages/react-dom/src/browser/browser.spec.tsx
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')
})
3 changes: 2 additions & 1 deletion packages/react-dom/src/index.ts
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'
185 changes: 185 additions & 0 deletions packages/react-dom/src/test-utils/index.ts
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?')
}
Loading

0 comments on commit cc053a2

Please sign in to comment.