diff --git a/configs/eslint-config/src/index.ts b/configs/eslint-config/src/index.ts index 70d4ea731..1fbfae704 100644 --- a/configs/eslint-config/src/index.ts +++ b/configs/eslint-config/src/index.ts @@ -85,10 +85,7 @@ export const suspensiveTypeScriptConfig: ReturnType = 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[number], diff --git a/examples/visualization/package.json b/examples/visualization/package.json index 3340aee60..7f1f94f80 100644 --- a/examples/visualization/package.json +++ b/examples/visualization/package.json @@ -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", diff --git a/examples/visualization/src/app/layout.tsx b/examples/visualization/src/app/layout.tsx index 716572dd9..f07ba9b00 100644 --- a/examples/visualization/src/app/layout.tsx +++ b/examples/visualization/src/app/layout.tsx @@ -54,6 +54,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) +
  • +
    + @suspensive/react-dom +
  • + {``} +
  • +
    +
  • @suspensive/react-query diff --git a/examples/visualization/src/app/react-dom/InView/page.tsx b/examples/visualization/src/app/react-dom/InView/page.tsx new file mode 100644 index 000000000..85ed5a92d --- /dev/null +++ b/examples/visualization/src/app/react-dom/InView/page.tsx @@ -0,0 +1,24 @@ +'use client' + +import { InView } from '@suspensive/react-dom' + +export default function Page() { + return ( +
    + {Array.from({ length: 200 }).map((_, i) => ( + // eslint-disable-next-line @eslint-react/no-duplicate-key + + {({ inView, ref }) => ( +
    + {inView ? ( +
    + ) : ( +
    + )} +
    + )} + + ))} +
    + ) +} diff --git a/package.json b/package.json index 698c3e001..834646baa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react-dom/package.json b/packages/react-dom/package.json index aac771975..35ca718f4 100644 --- a/packages/react-dom/package.json +++ b/packages/react-dom/package.json @@ -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" diff --git a/packages/react-dom/src/InView.spec.tsx b/packages/react-dom/src/InView.spec.tsx new file mode 100644 index 000000000..5f3818c81 --- /dev/null +++ b/packages/react-dom/src/InView.spec.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react' +import { InView } from './InView' +import { mockAllIsIntersecting } from './test-utils' + +describe('', () => { + it('should render intersecting', () => { + const callback = vi.fn() + render({({ inView, ref }) =>
    {inView.toString()}
    }
    ) + + 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 }) => InView: {inView.toString()}} + + ) + screen.getByText('InView: true') + }) + + // eslint-disable-next-line vitest/expect-expect + it('should unobserve old node', () => { + const { rerender } = render( + + {({ inView, ref }) => ( +
    + Inview: {inView.toString()} +
    + )} +
    + ) + rerender( + + {({ inView, ref }) => ( +
    + Inview: {inView.toString()} +
    + )} +
    + ) + mockAllIsIntersecting(true) + }) + + // eslint-disable-next-line vitest/expect-expect + it('should ensure node exists before observing and unobserving', () => { + const { unmount } = render({() => null}) + unmount() + }) +}) diff --git a/packages/react-dom/src/InView.tsx b/packages/react-dom/src/InView.tsx new file mode 100644 index 000000000..b06f4cb02 --- /dev/null +++ b/packages/react-dom/src/InView.tsx @@ -0,0 +1,9 @@ +import { type InViewOptions, useInView } from './useInView' + +interface InViewProps extends InViewOptions { + children: (inViewResult: ReturnType) => React.ReactNode +} + +export function InView({ children, ...options }: InViewProps) { + return <>{children(useInView(options))} +} diff --git a/packages/react-dom/src/TestText.spec.tsx b/packages/react-dom/src/TestText.spec.tsx deleted file mode 100644 index 2bfe1a698..000000000 --- a/packages/react-dom/src/TestText.spec.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { TestText } from './TestText' - -describe('', () => { - it('should render text "Test" with custom text', () => { - render() - - expect(screen.queryByText('Test')).toBeInTheDocument() - expect(screen.queryByText('Text')).not.toBeInTheDocument() - }) -}) diff --git a/packages/react-dom/src/TestText.tsx b/packages/react-dom/src/TestText.tsx deleted file mode 100644 index fd59f895f..000000000 --- a/packages/react-dom/src/TestText.tsx +++ /dev/null @@ -1 +0,0 @@ -export const TestText = () =>

    Test

    diff --git a/packages/react-dom/src/browser/browser.spec.tsx b/packages/react-dom/src/browser/browser.spec.tsx new file mode 100644 index 000000000..a9179b99d --- /dev/null +++ b/packages/react-dom/src/browser/browser.spec.tsx @@ -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 ( +
    + InView block +
    + ) +} + +test('should come into view on after rendering', async () => { + render() + const wrapper = screen.getByTestId('wrapper') + await expect.element(wrapper).toHaveAttribute('data-inview', 'true') +}) + +test('should come into view after scrolling', async () => { + render( + <> +
    + +
    + + ) + 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') +}) diff --git a/packages/react-dom/src/index.ts b/packages/react-dom/src/index.ts index 1846763a6..4ceebec90 100644 --- a/packages/react-dom/src/index.ts +++ b/packages/react-dom/src/index.ts @@ -1 +1,2 @@ -export { TestText } from './TestText' +export { InView } from './InView' +export { useInView } from './useInView' diff --git a/packages/react-dom/src/test-utils/index.ts b/packages/react-dom/src/test-utils/index.ts new file mode 100644 index 000000000..59a4805a6 --- /dev/null +++ b/packages/react-dom/src/test-utils/index.ts @@ -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 + created: number +} + +let isMocking = false + +const observers = new Map() + +// 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(), + 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({ + 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?') +} diff --git a/packages/react-dom/src/useInView.spec.tsx b/packages/react-dom/src/useInView.spec.tsx new file mode 100644 index 000000000..27355c50b --- /dev/null +++ b/packages/react-dom/src/useInView.spec.tsx @@ -0,0 +1,338 @@ +import { render, screen } from '@testing-library/react' +import React, { useCallback } from 'react' +import { intersectionMockInstance, mockAllIsIntersecting, mockIsIntersecting } from './test-utils' +import { type InViewOptions, useInView } from './useInView' + +type Mutable = { + -readonly [TKey in keyof TObject]: TObject[TKey] +} + +const HookComponent = ({ options, unmount }: { options?: InViewOptions; unmount?: boolean }) => { + const wrapper = useInView(options) + return ( +
    + {wrapper.inView.toString()} +
    + ) +} + +const LazyHookComponent = ({ options }: { options?: InViewOptions }) => { + const [isLoading, setIsLoading] = React.useState(true) + + React.useEffect(() => { + setIsLoading(false) + }, []) + const wrapper = useInView(options) + if (isLoading) return
    Loading
    + return ( +
    + {wrapper.inView.toString()} +
    + ) +} + +it('should create a hook', () => { + const { getByTestId } = render() + const wrapper = getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(instance.observe).toHaveBeenCalledWith(wrapper) +}) + +it('should create a hook with array threshold', () => { + const { getByTestId } = render() + const wrapper = getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(instance.observe).toHaveBeenCalledWith(wrapper) +}) + +it('should create a lazy hook', () => { + const { getByTestId } = render() + const wrapper = getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(instance.observe).toHaveBeenCalledWith(wrapper) +}) + +// eslint-disable-next-line vitest/expect-expect +it('should create a hook inView', () => { + const { getByText } = render() + mockAllIsIntersecting(true) + + getByText('true') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should mock thresholds', () => { + render() + mockAllIsIntersecting(0.2) + screen.getByText('false') + mockAllIsIntersecting(0.5) + screen.getByText('true') + mockAllIsIntersecting(1) + screen.getByText('true') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should create a hook with initialInView', () => { + const { getByText } = render() + getByText('true') + mockAllIsIntersecting(false) + getByText('false') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should trigger a hook leaving view', () => { + const { getByText } = render() + mockAllIsIntersecting(true) + mockAllIsIntersecting(false) + getByText('false') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should respect trigger once', () => { + const { getByText } = render() + mockAllIsIntersecting(true) + mockAllIsIntersecting(false) + + getByText('true') +}) + +it('should trigger onChange', () => { + const onChange = vi.fn() + render() + + mockAllIsIntersecting(true) + expect(onChange).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ intersectionRatio: 1, isIntersecting: true }) + ) + + mockAllIsIntersecting(false) + expect(onChange).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ intersectionRatio: 0, isIntersecting: false }) + ) +}) + +// eslint-disable-next-line vitest/expect-expect +it('should respect skip', () => { + const { getByText, rerender } = render() + mockAllIsIntersecting(false) + getByText('false') + + rerender() + mockAllIsIntersecting(true) + getByText('true') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should not reset current state if changing skip', () => { + const { getByText, rerender } = render() + mockAllIsIntersecting(true) + rerender() + getByText('true') +}) + +it('should unmount the hook', () => { + const { unmount, getByTestId } = render() + const wrapper = getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + unmount() + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(instance.unobserve).toHaveBeenCalledWith(wrapper) +}) + +// eslint-disable-next-line vitest/expect-expect +it('inView should be false when component is unmounted', () => { + const { rerender, getByText } = render() + mockAllIsIntersecting(true) + + getByText('true') + rerender() + getByText('false') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should handle trackVisibility', () => { + render() + mockAllIsIntersecting(true) +}) + +// eslint-disable-next-line vitest/expect-expect +it('should handle trackVisibility when unsupported', () => { + render() +}) + +const SwitchHookComponent = ({ + options, + toggle, + unmount, +}: { + options?: InViewOptions + toggle?: boolean + unmount?: boolean +}) => { + const wrapper = useInView(options) + return ( + <> +
    +
    + + ) +} + +/** + * This is a test for the case where people move the ref around (please don't) + */ +it('should handle ref removed', () => { + const { rerender, getByTestId } = render() + mockAllIsIntersecting(true) + + const item1 = getByTestId('item-1') + const item2 = getByTestId('item-2') + + // Item1 should be inView + expect(item1).toHaveAttribute('data-inview', 'true') + expect(item2).toHaveAttribute('data-inview', 'false') + + rerender() + mockAllIsIntersecting(true) + + // Item2 should be inView + expect(item1).toHaveAttribute('data-inview', 'false') + expect(item2).toHaveAttribute('data-inview', 'true') + + rerender() + + // Nothing should be inView + expect(item1).toHaveAttribute('data-inview', 'false') + expect(item2).toHaveAttribute('data-inview', 'false') + + // Add the ref back + rerender() + mockAllIsIntersecting(true) + expect(item1).toHaveAttribute('data-inview', 'true') + expect(item2).toHaveAttribute('data-inview', 'false') +}) + +const MergeRefsComponent = ({ options }: { options?: InViewOptions }) => { + const mergeInViewResult = useInView(options) + const setRef = useCallback( + (node: Element | null) => { + mergeInViewResult.ref(node) + }, + [mergeInViewResult.ref] + ) + + return
    +} + +it('should handle ref merged', () => { + const { rerender, getByTestId } = render() + mockAllIsIntersecting(true) + rerender() + + expect(getByTestId('inview')).toHaveAttribute('data-inview', 'true') +}) + +const MultipleHookComponent = ({ options }: { options?: InViewOptions }) => { + const el1 = useInView(options) + const el2 = useInView(options) + const el3 = useInView() + + const mergedRefs = useCallback( + (node: Element | null) => { + el1.ref(node) + el2.ref(node) + el3.ref(node) + }, + [el1.ref, el2.ref, el3.ref] + ) + + return ( +
    +
    + {el1.inView} +
    +
    + {el2.inView} +
    +
    + {el3.inView} +
    +
    + ) +} + +it('should handle multiple hooks on the same element', () => { + const { getByTestId } = render() + mockAllIsIntersecting(true) + expect(getByTestId('item-1')).toHaveAttribute('data-inview', 'true') + expect(getByTestId('item-2')).toHaveAttribute('data-inview', 'true') + expect(getByTestId('item-3')).toHaveAttribute('data-inview', 'true') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should handle thresholds missing on observer instance', () => { + render() + const wrapper = screen.getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + ;(instance as unknown as Partial>).thresholds = undefined + mockAllIsIntersecting(true) + + screen.getByText('true') +}) + +// eslint-disable-next-line vitest/expect-expect +it('should handle thresholds missing on observer instance with no threshold set', () => { + render() + const wrapper = screen.getByTestId('wrapper') + const instance = intersectionMockInstance(wrapper) + ;(instance as unknown as Partial>).thresholds = undefined + mockAllIsIntersecting(true) + + screen.getByText('true') +}) + +const HookComponentWithEntry = ({ options, unmount }: { options?: InViewOptions; unmount?: boolean }) => { + const { ref, entry } = useInView(options) + return ( +
    + {entry && Object.entries(entry).map(([key, value]) => `${key}: ${value}`)} +
    + ) +} + +// eslint-disable-next-line vitest/expect-expect +it('should set intersection ratio as the largest threshold smaller than trigger', () => { + render() + const wrapper = screen.getByTestId('wrapper') + + mockIsIntersecting(wrapper, 0.5) + screen.getByText(/intersectionRatio: 0.5/) +}) + +it('should handle fallback if unsupported', () => { + ;(window as unknown as { IntersectionObserver: IntersectionObserver | undefined }).IntersectionObserver = undefined + const { rerender } = render() + screen.getByText('true') + + rerender() + screen.getByText('false') + + expect(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + rerender() + vi.restoreAllMocks() + }).toThrowErrorMatchingInlineSnapshot(`[TypeError: IntersectionObserver is not a constructor]`) +}) diff --git a/packages/react-dom/src/useInView.tsx b/packages/react-dom/src/useInView.tsx new file mode 100644 index 000000000..198b9b3ac --- /dev/null +++ b/packages/react-dom/src/useInView.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { observe } from './utils/observe' + +export interface InViewOptions extends IntersectionObserverInit { + root?: Element | null + rootMargin?: string + threshold?: number | number[] + triggerOnce?: boolean + skip?: boolean + initialInView?: boolean + fallbackInView?: boolean + trackVisibility?: boolean + delay?: number + onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void +} +export function useInView({ + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + initialInView, + fallbackInView, + onChange, +}: InViewOptions = {}): { + ref: (node?: Element | null) => void + inView: boolean + entry?: IntersectionObserverEntry +} { + const [ref, setRef] = useState(null) + const onChangeRef = useRef() + const [state, setState] = useState<{ inView: boolean; entry?: IntersectionObserverEntry }>({ + inView: initialInView ?? false, + entry: undefined, + }) + onChangeRef.current = onChange + useEffect(() => { + if (skip || !ref) return + let unobserve: (() => void) | undefined + unobserve = observe( + ref, + (inView, entry) => { + setState({ inView, entry }) + onChangeRef.current?.(inView, entry) + if (entry.isIntersecting && triggerOnce && unobserve) { + unobserve() + unobserve = undefined + } + }, + { root, rootMargin, threshold, trackVisibility, delay }, + fallbackInView + ) + return unobserve + }, [ + Array.isArray(threshold) ? threshold.toString() : threshold, + ref, + root, + rootMargin, + triggerOnce, + skip, + trackVisibility, + fallbackInView, + delay, + ]) + const entryTarget = state.entry?.target + const previousEntryTarget = useRef() + if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { + previousEntryTarget.current = entryTarget + setState({ inView: initialInView ?? false, entry: undefined }) + } + return Object.assign(state, { ref: useCallback((node?: Element | null) => setRef(node ?? null), []) }) +} diff --git a/packages/react-dom/src/utils/observe.spec.ts b/packages/react-dom/src/utils/observe.spec.ts new file mode 100644 index 000000000..6ab4056d1 --- /dev/null +++ b/packages/react-dom/src/utils/observe.spec.ts @@ -0,0 +1,51 @@ +import { intersectionMockInstance, mockIsIntersecting } from '../test-utils' +import { observe, optionsToId } from './observe' + +test('should be able to use observe', () => { + const element = document.createElement('div') + const cb = vi.fn() + const unmount = observe(element, cb, { threshold: 0.1 }) + + mockIsIntersecting(element, true) + expect(cb).toHaveBeenCalled() + + // should be unmounted after unmount + unmount() + expect(() => intersectionMockInstance(element)).toThrowErrorMatchingInlineSnapshot( + `[Error: Failed to find IntersectionObserver for element. Is it being observed?]` + ) +}) + +test('should convert options to id', () => { + expect( + optionsToId({ + root: document.createElement('div'), + rootMargin: '10px 10px', + threshold: [0, 1], + }) + ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`) + expect( + optionsToId({ + root: null, + rootMargin: '10px 10px', + threshold: 1, + }) + ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`) + expect( + optionsToId({ + threshold: 0, + trackVisibility: true, + delay: 500, + }) + ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`) + expect( + optionsToId({ + threshold: 0, + }) + ).toMatchInlineSnapshot(`"threshold_0"`) + expect( + optionsToId({ + threshold: [0, 0.5, 1], + }) + ).toMatchInlineSnapshot(`"threshold_0,0.5,1"`) +}) diff --git a/packages/react-dom/src/utils/observe.ts b/packages/react-dom/src/utils/observe.ts new file mode 100644 index 000000000..af509a45b --- /dev/null +++ b/packages/react-dom/src/utils/observe.ts @@ -0,0 +1,122 @@ +type ObserverInstanceCallback = (inView: boolean, entry: IntersectionObserverEntry) => void + +const observerMap = new Map< + string, + { id: string; observer: IntersectionObserver; elements: Map> } +>() + +const RootIds: WeakMap = new WeakMap() +let rootId = 0 + +function getRootId(root: IntersectionObserverInit['root']) { + if (!root) return '0' + if (RootIds.has(root)) return RootIds.get(root) + rootId += 1 + RootIds.set(root, rootId.toString()) + return RootIds.get(root) +} + +export const optionsToId = (options: IntersectionObserverInit & { trackVisibility?: boolean; delay?: number }) => + Object.keys(options) + .sort() + .filter((key) => options[key as keyof IntersectionObserverInit] !== undefined) + .map((key) => `${key}_${key === 'root' ? getRootId(options.root) : options[key as keyof IntersectionObserverInit]}`) + .toString() + +function createObserver(options: IntersectionObserverInit & { trackVisibility?: boolean; delay?: number }) { + // Create a unique ID for this observer instance, based on the root, root margin and threshold. + const id = optionsToId(options) + let instance = observerMap.get(id) + + if (!instance) { + // Create a map of elements this observer is going to observe. Each element has a list of callbacks that should be triggered, once it comes into view. + const elements = new Map>() + let thresholds: number[] | readonly number[] = [] + + const observer = new IntersectionObserver((entries: Array) => { + entries.forEach((entry) => { + // While it would be nice if you could just look at isIntersecting to determine if the component is inside the viewport, browsers can't agree on how to use it. + // -Firefox ignores `threshold` when considering `isIntersecting`, so it will never be false again if `threshold` is > 0 + const inView = entry.isIntersecting && thresholds.some((threshold) => entry.intersectionRatio >= threshold) + + if (options.trackVisibility && typeof entry.isVisible === 'undefined') { + // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. + entry.isVisible = inView + } + + elements.get(entry.target)?.forEach((callback) => { + callback(inView, entry) + }) + }) + }, options) + + // Ensure we have a valid thresholds array. If not, use the threshold from the options + thresholds = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + observer.thresholds || (Array.isArray(options.threshold) ? options.threshold : [options.threshold || 0]) + + instance = { + id, + observer, + elements, + } + + observerMap.set(id, instance) + } + + return instance +} + +export function observe( + element: Element, + callback: ObserverInstanceCallback, + options: IntersectionObserverInit & { + trackVisibility?: boolean + delay?: number + } = {}, + fallbackInView?: boolean +) { + 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) + + // Register the callback listener for this element + const callbacks = elements.get(element) || [] + if (!elements.has(element)) { + elements.set(element, callbacks) + } + + callbacks.push(callback) + observer.observe(element) + + return function unobserve() { + // Remove the callback from the callback list + callbacks.splice(callbacks.indexOf(callback), 1) + + if (callbacks.length === 0) { + // No more callback exists for element, so destroy it + elements.delete(element) + observer.unobserve(element) + } + + if (elements.size === 0) { + // No more elements are being observer by this instance, so destroy it + observer.disconnect() + observerMap.delete(id) + } + } +} diff --git a/packages/react-dom/tsconfig.json b/packages/react-dom/tsconfig.json index 2176a2e69..c1d43485e 100644 --- a/packages/react-dom/tsconfig.json +++ b/packages/react-dom/tsconfig.json @@ -2,6 +2,6 @@ "extends": "@suspensive/tsconfig/react-library.json", "include": [".", "eslint.config.mjs"], "compilerOptions": { - "types": ["@testing-library/jest-dom/vitest", "vitest/globals"] + "types": ["@testing-library/jest-dom/vitest", "vitest/globals", "@vitest/browser/providers/playwright"] } } diff --git a/packages/react-dom/vitest.config.ts b/packages/react-dom/vitest.config.ts index fa9d99e50..4d34c18e5 100644 --- a/packages/react-dom/vitest.config.ts +++ b/packages/react-dom/vitest.config.ts @@ -1,11 +1,9 @@ import { defineConfig } from 'vitest/config' -import packageJson from './package.json' export default defineConfig({ test: { - name: packageJson.name, - dir: './src', environment: 'jsdom', + dir: './src', globals: true, setupFiles: './vitest.setup.ts', coverage: { diff --git a/packages/react-dom/vitest.workspace.ts b/packages/react-dom/vitest.workspace.ts new file mode 100644 index 000000000..6a190eb4b --- /dev/null +++ b/packages/react-dom/vitest.workspace.ts @@ -0,0 +1,27 @@ +import { defineWorkspace } from 'vitest/config' + +export default defineWorkspace([ + { + extends: 'vitest.config.ts', + test: { + include: ['**/*.{spec,test}.{ts,tsx}'], + exclude: ['browser/*.{spec,test}.{ts,tsx}'], + name: 'jsdom', + environment: 'jsdom', + }, + }, + { + extends: 'vitest.config.ts', + test: { + include: ['browser/*.{spec,test}.{ts,tsx}'], + name: 'browser', + environment: 'node', + browser: { + enabled: true, + headless: true, + provider: 'playwright', + name: 'chromium', + }, + }, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28d448ef2..1b1a9b3bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: '@vitest/coverage-istanbul': specifier: ^2.0.5 version: 2.0.5(vitest@2.0.5(@types/node@20.14.13)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(terser@5.31.3)) + '@vitest/coverage-v8': + specifier: ^2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.14.13)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(terser@5.31.3)) '@vitest/ui': specifier: ^2.0.5 version: 2.0.5(vitest@2.0.5) @@ -471,6 +474,9 @@ importers: '@suspensive/react': specifier: workspace:* version: link:../../packages/react + '@suspensive/react-dom': + specifier: workspace:* + version: link:../../packages/react-dom '@suspensive/react-image': specifier: workspace:* version: link:../../packages/react-image @@ -654,9 +660,15 @@ importers: '@types/react': specifier: catalog:react18 version: 18.3.5 + '@types/react-dom': + specifier: catalog:react18 + version: 18.3.0 react: specifier: catalog:react18 version: 18.3.1 + react-dom: + specifier: catalog:react18 + version: 18.3.1(react@18.3.1) packages/react-image: dependencies: @@ -969,12 +981,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.24.7': - resolution: {integrity: sha512-03TCmXy2FtXJEZfbXDTSqq1fRJArk7lX9DOFC/47VthYcxyIOx+eXQmdo6DOQvrbpIix+KfXwvuXdFDZHxt+rA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.25.0': resolution: {integrity: sha512-q0T+dknZS+L5LDazIP+02gEZITG5unzvb6yIjcmj5i0eFrs5ToBV2m2JGH4EsE/gtP8ygEGLGApBgRIZkTm7zg==} engines: {node: '>=6.9.0'} @@ -1006,12 +1012,6 @@ packages: resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.24.9': - resolution: {integrity: sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.25.0': resolution: {integrity: sha512-bIkOa2ZJYn7FHnepzr5iX9Kmz8FjIz4UKzJ9zhX3dnYuVW0xul9RuR3skBfoLu+FPTQw90EHW9rJsSZhyLQ3fQ==} engines: {node: '>=6.9.0'} @@ -1026,12 +1026,6 @@ packages: resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} engines: {node: '>=6.9.0'} - '@babel/helper-remap-async-to-generator@7.24.7': - resolution: {integrity: sha512-9pKLcTlZ92hNZMQfGCHImUpDOlAgkkpqalWEeftW5FBya75k8Li2ilerxkM/uBEj01iBZXcCIB/bwvDYgWyibA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-remap-async-to-generator@7.25.0': resolution: {integrity: sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==} engines: {node: '>=6.9.0'} @@ -1074,10 +1068,6 @@ packages: resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} - '@babel/helper-wrap-function@7.24.7': - resolution: {integrity: sha512-N9JIYk3TD+1vq/wn77YnJOqMtfWhNewNE+DJV4puD2X7Ew9J4JvrzrFDfTfyv5EgEXVy9/Wt8QiOErzEmv5Ifw==} - engines: {node: '>=6.9.0'} - '@babel/helper-wrap-function@7.25.0': resolution: {integrity: sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==} engines: {node: '>=6.9.0'} @@ -1366,12 +1356,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoping@7.24.7': - resolution: {integrity: sha512-Nd5CvgMbWc+oWzBsuaMcbwjJWAcp5qzrbg69SZdHSP7AMY0AbWFqFO0WTFCA1jxhMCwodRwvRec8k0QUbZk7RQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoping@7.25.0': resolution: {integrity: sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==} engines: {node: '>=6.9.0'} @@ -1390,12 +1374,6 @@ packages: peerDependencies: '@babel/core': ^7.12.0 - '@babel/plugin-transform-classes@7.24.8': - resolution: {integrity: sha512-VXy91c47uujj758ud9wx+OMgheXm4qJfyhj1P18YvlrQkNOSrwsteHk+EFS3OMGfhMhpZa0A+81eE7G4QC+3CA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-classes@7.25.0': resolution: {integrity: sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==} engines: {node: '>=6.9.0'} @@ -1462,12 +1440,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-function-name@7.24.7': - resolution: {integrity: sha512-U9FcnA821YoILngSmYkW6FjyQe2TyZD5pHt4EVIhmcTkrJw/3KqcrRSxuOo5tFZJi7TE19iDyI1u+weTI7bn2w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-function-name@7.25.0': resolution: {integrity: sha512-CQmfSnK14eYu82fu6GlCwRciHB7mp7oLN+DeyGDDwUr9cMwuSVviJKPXw/YcRYZdB1TdlLJWHHwXwnwD1WnCmQ==} engines: {node: '>=6.9.0'} @@ -2671,72 +2643,84 @@ packages: engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.2': resolution: {integrity: sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==} engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.2': resolution: {integrity: sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==} engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.2': resolution: {integrity: sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==} engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.2': resolution: {integrity: sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==} engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.2': resolution: {integrity: sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==} engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.4': resolution: {integrity: sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.4': resolution: {integrity: sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==} engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.4': resolution: {integrity: sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==} engines: {glibc: '>=2.31', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.4': resolution: {integrity: sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.4': resolution: {integrity: sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.4': resolution: {integrity: sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.4': resolution: {integrity: sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==} @@ -2965,36 +2949,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-arm64-musl@0.1.17': resolution: {integrity: sha512-PRdVIEvgdIuJhDvdneO3X7XfZwujU7MOyymwK3kR1RMJPlbwzxdQBA86am/jEkBP7d8Cx8RbREzJ6y/2hAHKOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/simple-git-linux-powerpc64le-gnu@0.1.17': resolution: {integrity: sha512-afbfsJMpQjtdLP3BRGj/hKpRqymxw2Lt+dmyoRej0zKxZnuPrws3Fi85RyYsT/6Tq0hSUAMeh5UtxGAOH3q8gA==} engines: {node: '>= 10'} cpu: [powerpc64le] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-s390x-gnu@0.1.17': resolution: {integrity: sha512-qTgRIUsU+b7RMls+Ji4xlDYq0rsUuNBpzVgb991UPnzrhFWFFkCtyk6I6tJqMtRfg7Vgn1stCghFEQiHmpqkew==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-x64-gnu@0.1.17': resolution: {integrity: sha512-xHlyUDJhjPUCR07JGrvMfLg5XSRVDsxgpo6B6zYQOSMcVgM7fjvyWNMBe508r4eD5YZKZyBPfSJUc5Ls9ToJNQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-x64-musl@0.1.17': resolution: {integrity: sha512-eaTr+WPeiuEegduE3O7VzHhHftGXmX1pzzILoOTbbdmeEuH1BHnGAr35XTu+1lUHUqE2JHef3d3PgBHeh844hA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/simple-git-win32-arm64-msvc@0.1.17': resolution: {integrity: sha512-v1F72stOCjapCd0Ha928m8X8i/IPhPQIXbYEGX0MEmaaAzbAJ3PTSSFpb0rFLShXaDFA2Wuw/jzlkPLESPdKVQ==} @@ -3035,24 +3025,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.5': resolution: {integrity: sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.5': resolution: {integrity: sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.5': resolution: {integrity: sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.5': resolution: {integrity: sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==} @@ -3281,91 +3275,109 @@ packages: resolution: {integrity: sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.21.2': resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.19.0': resolution: {integrity: sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.21.2': resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.19.0': resolution: {integrity: sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.21.2': resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.19.0': resolution: {integrity: sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.21.2': resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.19.0': resolution: {integrity: sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.21.2': resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.19.0': resolution: {integrity: sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.21.2': resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.19.0': resolution: {integrity: sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.21.2': resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.19.0': resolution: {integrity: sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.21.2': resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.19.0': resolution: {integrity: sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.21.2': resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.19.0': resolution: {integrity: sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==} @@ -3996,6 +4008,11 @@ packages: peerDependencies: vitest: 2.0.5 + '@vitest/coverage-v8@2.0.5': + resolution: {integrity: sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==} + peerDependencies: + vitest: 2.0.5 + '@vitest/eslint-plugin@1.0.3': resolution: {integrity: sha512-7hTONh+lqN+TEimHy2aWVdHVqYohcxLGD4yYBwSVvhyiti/j9CqBNMQvOa6xLoVcEtaWAoCCDbYgvxwNqA4lsA==} peerDependencies: @@ -4387,9 +4404,6 @@ packages: aws4@1.13.0: resolution: {integrity: sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==} - axios@1.7.4: - resolution: {integrity: sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==} - axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} @@ -4656,9 +4670,6 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001643: - resolution: {integrity: sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==} - caniuse-lite@1.0.30001651: resolution: {integrity: sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==} @@ -7636,24 +7647,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.19.0: resolution: {integrity: sha512-vSCKO7SDnZaFN9zEloKSZM5/kC5gbzUjoJQ43BvUpyTFUX7ACs/mDfl2Eq6fdz2+uWhUh7vf92c4EaaP4udEtA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.19.0: resolution: {integrity: sha512-0AFQKvVzXf9byrXUq9z0anMGLdZJS+XSDqidyijI5njIwj6MdbvX2UZK/c4FfNmeRa2N/8ngTffoIuOUit5eIQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.19.0: resolution: {integrity: sha512-SJoM8CLPt6ECCgSuWe+g0qo8dqQYVcPiW2s19dxkmSI5+Uu1GIRzyKA0b7QqmEXolA+oSJhQqCmJpzjY4CuZAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-x64-msvc@1.19.0: resolution: {integrity: sha512-C+VuUTeSUOAaBZZOPT7Etn/agx/MatzJzGRkeV+zEABmPuntv1zihncsi+AyGmjkkzq3wVedEy7h0/4S84mUtg==} @@ -11476,13 +11491,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.24.7(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-annotate-as-pure': 7.24.7 - regexpu-core: 5.3.2 - semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -11528,17 +11536,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.24.9(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-simple-access': 7.24.7 - '@babel/helper-split-export-declaration': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -11555,15 +11552,6 @@ snapshots: '@babel/helper-plugin-utils@7.24.8': {} - '@babel/helper-remap-async-to-generator@7.24.7(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-wrap-function': 7.24.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-remap-async-to-generator@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -11615,15 +11603,6 @@ snapshots: '@babel/helper-validator-option@7.24.8': {} - '@babel/helper-wrap-function@7.24.7': - dependencies: - '@babel/helper-function-name': 7.24.7 - '@babel/template': 7.24.7 - '@babel/traverse': 7.24.8 - '@babel/types': 7.24.9 - transitivePeerDependencies: - - supports-color - '@babel/helper-wrap-function@7.25.0': dependencies: '@babel/template': 7.25.0 @@ -11692,7 +11671,7 @@ snapshots: '@babel/core': 7.24.9 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-remap-async-to-generator': 7.24.7(@babel/core@7.24.9) + '@babel/helper-remap-async-to-generator': 7.25.0(@babel/core@7.24.9) '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.9) transitivePeerDependencies: - supports-color @@ -11924,11 +11903,6 @@ snapshots: '@babel/core': 7.24.9 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-block-scoping@7.24.7(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-block-scoping@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -11951,20 +11925,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.24.8(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-compilation-targets': 7.24.8 - '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.9) - '@babel/helper-split-export-declaration': 7.24.7 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-classes@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -11981,7 +11941,7 @@ snapshots: dependencies: '@babel/core': 7.24.9 '@babel/helper-plugin-utils': 7.24.8 - '@babel/template': 7.24.7 + '@babel/template': 7.25.0 '@babel/plugin-transform-destructuring@7.24.8(@babel/core@7.24.9)': dependencies: @@ -12039,13 +11999,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.24.7(@babel/core@7.24.9)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-compilation-targets': 7.24.8 - '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-function-name@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -12088,7 +12041,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.24.8(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 - '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.9) + '@babel/helper-module-transforms': 7.25.0(@babel/core@7.24.9) '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-simple-access': 7.24.7 transitivePeerDependencies: @@ -12115,7 +12068,7 @@ snapshots: '@babel/plugin-transform-named-capturing-groups-regex@7.24.7(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 - '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.9) + '@babel/helper-create-regexp-features-plugin': 7.25.0(@babel/core@7.24.9) '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-new-target@7.24.7(@babel/core@7.24.9)': @@ -12223,7 +12176,7 @@ snapshots: '@babel/helper-module-imports': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.9) - '@babel/types': 7.24.9 + '@babel/types': 7.25.0 transitivePeerDependencies: - supports-color @@ -12308,7 +12261,7 @@ snapshots: '@babel/plugin-transform-unicode-regex@7.24.7(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 - '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.9) + '@babel/helper-create-regexp-features-plugin': 7.25.0(@babel/core@7.24.9) '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-unicode-sets-regex@7.24.7(@babel/core@7.24.9)': @@ -12810,7 +12763,7 @@ snapshots: '@codspeed/core@3.1.1': dependencies: - axios: 1.7.4 + axios: 1.7.7 find-up: 6.3.0 form-data: 4.0.0 node-gyp-build: 4.8.1 @@ -14534,12 +14487,12 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.9) '@babel/plugin-transform-arrow-functions': 7.24.7(@babel/core@7.24.9) '@babel/plugin-transform-async-to-generator': 7.24.7(@babel/core@7.24.9) - '@babel/plugin-transform-block-scoping': 7.24.7(@babel/core@7.24.9) - '@babel/plugin-transform-classes': 7.24.8(@babel/core@7.24.9) + '@babel/plugin-transform-block-scoping': 7.25.0(@babel/core@7.24.9) + '@babel/plugin-transform-classes': 7.25.0(@babel/core@7.24.9) '@babel/plugin-transform-computed-properties': 7.24.7(@babel/core@7.24.9) '@babel/plugin-transform-destructuring': 7.24.8(@babel/core@7.24.9) '@babel/plugin-transform-flow-strip-types': 7.24.7(@babel/core@7.24.9) - '@babel/plugin-transform-function-name': 7.24.7(@babel/core@7.24.9) + '@babel/plugin-transform-function-name': 7.25.0(@babel/core@7.24.9) '@babel/plugin-transform-literals': 7.24.7(@babel/core@7.24.9) '@babel/plugin-transform-modules-commonjs': 7.24.8(@babel/core@7.24.9) '@babel/plugin-transform-named-capturing-groups-regex': 7.24.7(@babel/core@7.24.9) @@ -14556,7 +14509,7 @@ snapshots: '@babel/plugin-transform-sticky-regex': 7.24.7(@babel/core@7.24.9) '@babel/plugin-transform-typescript': 7.24.8(@babel/core@7.24.9) '@babel/plugin-transform-unicode-regex': 7.24.7(@babel/core@7.24.9) - '@babel/template': 7.24.7 + '@babel/template': 7.25.0 '@react-native/babel-plugin-codegen': 0.74.85(@babel/preset-env@7.25.0(@babel/core@7.24.9)) babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.24.9) react-refresh: 0.14.2 @@ -15464,6 +15417,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.14.13)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(terser@5.31.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.6 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.10 + magicast: 0.3.4 + std-env: 3.7.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.0.5(@types/node@20.14.13)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(terser@5.31.3) + transitivePeerDependencies: + - supports-color + '@vitest/eslint-plugin@1.0.3(@typescript-eslint/utils@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)(vitest@2.0.5(@types/node@20.14.13)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(terser@5.31.3))': dependencies: eslint: 9.9.1(jiti@1.21.6) @@ -15870,14 +15841,6 @@ snapshots: aws4@1.13.0: {} - axios@1.7.4: - dependencies: - follow-redirects: 1.15.6 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.7.7: dependencies: follow-redirects: 1.15.6 @@ -16139,7 +16102,7 @@ snapshots: browserslist@4.23.2: dependencies: - caniuse-lite: 1.0.30001643 + caniuse-lite: 1.0.30001651 electron-to-chromium: 1.5.1 node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.2) @@ -16256,8 +16219,6 @@ snapshots: lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001643: {} - caniuse-lite@1.0.30001651: {} caseless@0.12.0: {} @@ -21010,7 +20971,7 @@ snapshots: '@next/env': 14.2.5 '@swc/helpers': 0.5.5 busboy: 1.6.0 - caniuse-lite: 1.0.30001643 + caniuse-lite: 1.0.30001651 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1