diff --git a/.changeset/eleven-mugs-buy.md b/.changeset/eleven-mugs-buy.md
new file mode 100644
index 00000000000..93b8d402377
--- /dev/null
+++ b/.changeset/eleven-mugs-buy.md
@@ -0,0 +1,5 @@
+---
+"@clerk/ui": patch
+---
+
+Add infinite loading to organization selection in ``.
diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
index 6802e665807..c71d2d06bb6 100644
--- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
+++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
@@ -35,10 +35,8 @@ function _OAuthConsent() {
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({
- // TODO(rob): Implement lazy loading in another PR
- userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined,
+ userMemberships: ctx.enableOrgSelection ? { infinite: true } : undefined,
});
-
const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({
value: m.organization.id,
label: m.organization.name,
@@ -228,6 +226,8 @@ function _OAuthConsent() {
options={orgOptions}
value={effectiveOrg}
onChange={setSelectedOrg}
+ hasMore={userMemberships.hasNextPage}
+ onLoadMore={userMemberships.fetchNext}
/>
)}
diff --git a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx
index aac8314e78b..5579f3c3de0 100644
--- a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx
+++ b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx
@@ -1,7 +1,9 @@
import { useRef } from 'react';
+import { InfiniteListSpinner } from '@/ui/common/InfiniteListSpinner';
import { Box, Icon, Image, Text } from '@/ui/customizables';
import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select';
+import { useInView } from '@/ui/hooks/useInView';
import { Check } from '@/ui/icons';
import { common } from '@/ui/styledSystem';
@@ -15,11 +17,21 @@ type OrgSelectProps = {
options: OrgOption[];
value: string | null;
onChange: (value: string) => void;
+ hasMore?: boolean;
+ onLoadMore?: () => void;
};
-export function OrgSelect({ options, value, onChange }: OrgSelectProps) {
+export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: OrgSelectProps) {
const buttonRef = useRef(null);
const selected = options.find(option => option.value === value);
+ const { ref: loadMoreRef } = useInView({
+ threshold: 0,
+ onChange: inView => {
+ if (inView && hasMore) {
+ onLoadMore?.();
+ }
+ },
+ });
return (
);
}
diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx
index 643e74d1a1d..e871fd41fb1 100644
--- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx
+++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx
@@ -1,12 +1,15 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
import { useOrganizationList } from '@clerk/shared/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, waitFor } from '@/test/utils';
import { OAuthConsent } from '../OAuthConsent';
+// Captures the onChange injected into SelectOptionList's useInView so tests
+// can simulate "user scrolled to the bottom of the org dropdown".
+let capturedLoadMoreOnChange: ((inView: boolean) => void) | undefined;
+
// Default: useOrganizationList returns no memberships and is not loaded.
// Individual tests override this mock to inject org data.
vi.mock('@clerk/shared/react', async importOriginal => {
@@ -15,11 +18,18 @@ vi.mock('@clerk/shared/react', async importOriginal => {
...actual,
useOrganizationList: vi.fn().mockReturnValue({
isLoaded: false,
- userMemberships: { data: [] },
+ userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: false },
}),
};
});
+vi.mock('@/ui/hooks/useInView', () => ({
+ useInView: vi.fn().mockImplementation(({ onChange }: { onChange?: (inView: boolean) => void }) => {
+ capturedLoadMoreOnChange = onChange;
+ return { ref: vi.fn(), inView: false };
+ }),
+}));
+
const { createFixtures } = bindCreateFixtures('OAuthConsent');
const fakeConsentInfo = {
@@ -56,6 +66,7 @@ describe('OAuthConsent', () => {
const originalLocation = window.location;
beforeEach(() => {
+ capturedLoadMoreOnChange = undefined;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
@@ -431,4 +442,54 @@ describe('OAuthConsent', () => {
});
});
});
+
+ describe('org selection — infinite scroll', () => {
+ const twoOrgs = [
+ { organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' } },
+ { organization: { id: 'org_2', name: 'Beta Inc', imageUrl: 'https://img.clerk.com/static/beta.png' } },
+ ];
+
+ it('calls fetchNext when the load-more sentinel enters view and more pages are available', async () => {
+ const fetchNext = vi.fn();
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['jane@example.com'] });
+ });
+
+ props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
+ mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
+
+ vi.mocked(useOrganizationList).mockReturnValue({
+ isLoaded: true,
+ userMemberships: { data: twoOrgs, hasNextPage: true, fetchNext, isLoading: false },
+ } as any);
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined());
+
+ capturedLoadMoreOnChange!(true);
+ expect(fetchNext).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchNext when hasNextPage is false', async () => {
+ const fetchNext = vi.fn();
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['jane@example.com'] });
+ });
+
+ props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
+ mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
+
+ vi.mocked(useOrganizationList).mockReturnValue({
+ isLoaded: true,
+ userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext, isLoading: false },
+ } as any);
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined());
+ capturedLoadMoreOnChange!(true);
+ expect(fetchNext).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx
index 3a32ca5e9d5..1dfb229f32c 100644
--- a/packages/ui/src/elements/Select.tsx
+++ b/packages/ui/src/elements/Select.tsx
@@ -235,10 +235,12 @@ export const SelectNoResults = (props: PropsOfComponent) => {
type SelectOptionListProps = PropsOfComponent & {
containerSx?: ThemableCssProp;
+ footer?: React.ReactNode;
+ onReachEnd?: () => void;
};
export const SelectOptionList = (props: SelectOptionListProps) => {
- const { containerSx, sx, ...rest } = props;
+ const { containerSx, sx, footer, onReachEnd, ...rest } = props;
const {
popoverCtx,
searchInputCtx,
@@ -294,6 +296,10 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (isOpen) {
+ if (onReachEnd && focusedIndex === options.length - 1) {
+ onReachEnd();
+ return;
+ }
return setFocusedIndex((i = 0) => (i === options.length - 1 ? 0 : i + 1));
}
return onTriggerClick();
@@ -376,6 +382,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
);
})}
{noResultsMessage && options.length === 0 && {noResultsMessage}}
+ {footer}