diff --git a/.changeset/lovely-rainbows-cry.md b/.changeset/lovely-rainbows-cry.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/lovely-rainbows-cry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.integration.test.tsx index 40e83c6cdc1..38e86877395 100644 --- a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.integration.test.tsx @@ -97,7 +97,8 @@ vi.mock('@clerk/shared/react', async importOriginal => { // Stubbed at the same seam as the controller test: the in-view helper is the controller's whole // fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted. -vi.mock('../../../../hooks/useOrganizationListInView', () => ({ + +vi.mock('../../../hooks/useOrganizationListInView', () => ({ useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), })); diff --git a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx index 066341c2421..4c17845117f 100644 --- a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx @@ -4,7 +4,7 @@ import type { CustomPage } from '@clerk/shared/types'; import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useOrganizationListInView } from '../../../../hooks/useOrganizationListInView'; +import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; import type { UserButtonModalProps, UserButtonModelOptions } from '../user-button.model'; import { useUserButtonModel } from '../user-button.model'; @@ -104,9 +104,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -// The model reads its three paginated lists through the shared in-view helper, so the fetch +// The model reads its three paginated lists through the Mosaic in-view helper, so the fetch // boundary is stubbed there rather than at `useOrganizationList`. -vi.mock('../../../../hooks/useOrganizationListInView', () => ({ +vi.mock('../../../hooks/useOrganizationListInView', () => ({ useOrganizationListInView: vi.fn(() => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef })), })); diff --git a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx index 6f878218ecf..9631acb8b3d 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx @@ -11,9 +11,9 @@ import type { } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; -import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; import { useMosaicRouter } from '../../hooks/useMosaicRouter'; +import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; import type { UserButtonBrandingProps, UserButtonCallbacks, diff --git a/packages/ui/src/mosaic/hooks/useInView.ts b/packages/ui/src/mosaic/hooks/useInView.ts new file mode 100644 index 00000000000..da2efc1a960 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useInView.ts @@ -0,0 +1,50 @@ +import { useCallback, useRef, useState } from 'react'; + +interface IntersectionOptions extends IntersectionObserverInit { + triggerOnce?: boolean; + onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; +} + +export const useInView = (params: IntersectionOptions) => { + const [inView, setInView] = useState(false); + const observerRef = useRef(null); + const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; + const internalOnChange = useRef(); + + internalOnChange.current = params.onChange; + + const ref = useCallback((element: HTMLElement | null) => { + if (!element) { + if (observerRef.current) { + observerRef.current.disconnect(); + } + return; + } + + observerRef.current = new IntersectionObserver( + entries => { + entries.forEach(entry => { + const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + setInView(_inView); + + if (internalOnChange.current) { + internalOnChange.current(_inView, entry); + } + }); + }, + { + root: params.root, + rootMargin: params.rootMargin, + threshold: thresholds, + }, + ); + + observerRef.current.observe(element); + }, []); + + return { + inView, + ref, + }; +}; diff --git a/packages/ui/src/mosaic/hooks/useOrganizationListInView.ts b/packages/ui/src/mosaic/hooks/useOrganizationListInView.ts new file mode 100644 index 00000000000..3ae5cb2a826 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useOrganizationListInView.ts @@ -0,0 +1,45 @@ +import { useOrganizationList } from '@clerk/shared/react'; + +import { useInView } from './useInView'; + +const organizationListParams = { + userMemberships: { + infinite: true, + }, + userInvitations: { + infinite: true, + }, + userSuggestions: { + infinite: true, + status: ['pending', 'accepted'], + }, +} satisfies Parameters[0]; + +export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => { + const { userMemberships, userInvitations, userSuggestions } = useOrganizationList( + enabled ? organizationListParams : undefined, + ); + + const { ref } = useInView({ + threshold: 0, + onChange: inView => { + if (!enabled || !inView) { + return; + } + if (userMemberships.hasNextPage) { + userMemberships.fetchNext?.(); + } else if (userInvitations.hasNextPage) { + userInvitations.fetchNext?.(); + } else { + userSuggestions.fetchNext?.(); + } + }, + }); + + return { + userMemberships, + userInvitations, + userSuggestions, + ref, + }; +};