Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-mugs-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/ui": patch
---

Add infinite loading to organization selection in `<OAuthConsent />`.
6 changes: 3 additions & 3 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -228,6 +226,8 @@ function _OAuthConsent() {
options={orgOptions}
value={effectiveOrg}
onChange={setSelectedOrg}
hasMore={userMemberships.hasNextPage}
onLoadMore={userMemberships.fetchNext}
/>
)}
<ListGroup>
Expand Down
19 changes: 17 additions & 2 deletions packages/ui/src/components/OAuthConsent/OrgSelect.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<HTMLButtonElement>(null);
const selected = options.find(option => option.value === value);
const { ref: loadMoreRef } = useInView({
threshold: 0,
onChange: inView => {
if (inView && hasMore) {
onLoadMore?.();
}
},
});

return (
<Select
Expand Down Expand Up @@ -101,7 +113,10 @@ export function OrgSelect({ options, value, onChange }: OrgSelectProps) {
{selected?.label || 'Select an option'}
</Text>
</SelectButton>
<SelectOptionList />
<SelectOptionList
footer={hasMore ? <InfiniteListSpinner ref={loadMoreRef} /> : null}
onReachEnd={hasMore ? onLoadMore : undefined}
/>
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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 = {
Expand Down Expand Up @@ -56,6 +66,7 @@ describe('OAuthConsent', () => {
const originalLocation = window.location;

beforeEach(() => {
capturedLoadMoreOnChange = undefined;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
Expand Down Expand Up @@ -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(<OAuthConsent />, { 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(<OAuthConsent />, { wrapper });

await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined());
capturedLoadMoreOnChange!(true);
expect(fetchNext).not.toHaveBeenCalled();
});
});
});
9 changes: 8 additions & 1 deletion packages/ui/src/elements/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,12 @@ export const SelectNoResults = (props: PropsOfComponent<typeof Text>) => {

type SelectOptionListProps = PropsOfComponent<typeof Flex> & {
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,
Expand Down Expand Up @@ -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;
}
Comment on lines +299 to +302
Copy link
Copy Markdown
Member Author

@wobsoriano wobsoriano Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not break existing components interactions like the phone number input. Only invokes the method if it exists

return setFocusedIndex((i = 0) => (i === options.length - 1 ? 0 : i + 1));
}
return onTriggerClick();
Expand Down Expand Up @@ -376,6 +382,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
);
})}
{noResultsMessage && options.length === 0 && <SelectNoResults>{noResultsMessage}</SelectNoResults>}
{footer}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's probably a better way there but simplest is just adding an element here to keep pagination concerns out of the shared primitive. The OrgSelect component owns the scroll detection logic and passes the spinner in as a footer when there are more pages to load

</Flex>
</Flex>
</Popover>
Expand Down
Loading