From b5e622bb9f7d1aa7c4b694f90f5373d902182c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 15 Sep 2026 12:49:51 +0200 Subject: [PATCH 1/3] refactor(ui): duplicate useInView hooks for Mosaic Duplicates useInView and useOrganizationListInView, this breaks a Mosaic->ui dependency. Duplication was chosen over moving to shared as these are UI-concerns that are allowed to drift. --- .../user-button.integration.test.tsx | 7 +-- .../__tests__/user-button.model.test.tsx | 14 +++--- .../user-button/user-button.model.tsx | 2 +- packages/ui/src/mosaic/hooks/useInView.ts | 50 +++++++++++++++++++ .../mosaic/hooks/useOrganizationListInView.ts | 45 +++++++++++++++++ 5 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/mosaic/hooks/useInView.ts create mode 100644 packages/ui/src/mosaic/hooks/useOrganizationListInView.ts 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..b78e7b8d46c 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 }), })); @@ -120,8 +121,8 @@ function list(data: unknown[], count: number, hasNextPage = false, isLoading = f /** A promise whose settling is controlled by the test, to hold an async action in flight. */ function createDeferred() { - let resolve: () => void = () => {}; - let reject: (reason?: unknown) => void = () => {}; + let resolve: () => void = () => { }; + let reject: (reason?: unknown) => void = () => { }; const promise = new Promise((res, rej) => { resolve = res; reject = rej; 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..839559dd8ef 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'; @@ -57,10 +57,10 @@ let environmentHydrated: boolean; function environment() { return environmentHydrated ? { - displayConfig: { afterSwitchSessionUrl, branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } + displayConfig: { afterSwitchSessionUrl, branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } : null; } @@ -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, + }; +}; From 1d5f0364546f0f766ff7339268dc8f33ed892de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 15 Sep 2026 13:05:45 +0200 Subject: [PATCH 2/3] Add changeset --- .changeset/lovely-rainbows-cry.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/lovely-rainbows-cry.md 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 @@ +--- +--- From 03e162cf25af565aad082d5e17516d20d2249f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 16 Sep 2026 11:46:54 +0200 Subject: [PATCH 3/3] Fix format --- .../__tests__/user-button.integration.test.tsx | 4 ++-- .../user-button/__tests__/user-button.model.test.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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 b78e7b8d46c..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 @@ -121,8 +121,8 @@ function list(data: unknown[], count: number, hasNextPage = false, isLoading = f /** A promise whose settling is controlled by the test, to hold an async action in flight. */ function createDeferred() { - let resolve: () => void = () => { }; - let reject: (reason?: unknown) => void = () => { }; + let resolve: () => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; const promise = new Promise((res, rej) => { resolve = res; reject = rej; 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 839559dd8ef..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 @@ -57,10 +57,10 @@ let environmentHydrated: boolean; function environment() { return environmentHydrated ? { - displayConfig: { afterSwitchSessionUrl, branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } + displayConfig: { afterSwitchSessionUrl, branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } : null; }