From 30ffaa3531aa021502b4c4e97160f5908fda594f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 14 Apr 2026 17:53:01 -0700 Subject: [PATCH 01/10] docs: add accounts portal OAuth consent refactor spec --- ...-accounts-oauth-consent-refactor-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md new file mode 100644 index 00000000000..212aab20054 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md @@ -0,0 +1,88 @@ +# Accounts Portal OAuth Consent Refactor Design + +> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. + +**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. + +**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. + +--- + +## Files Deleted + +- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms +- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility +- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) + +## Files Modified + +### `types/index.ts` + +Remove the re-export of the deleted type file: + +```diff +- export * from './OAuthConsent'; + export * from './AccountPortalJSON'; + export * from './constants'; +``` + +`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. + +### `pages/oauth-consent/[[...index]].tsx` + +Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). + +```tsx +import React from 'react'; +import Head from 'next/head'; +import { OAuthConsent } from '@clerk/nextjs/internal'; + +export default function ConsentPage(): JSX.Element { + return ( +
+
+ + + + +
+
+ ); +} +``` + +### `e2e/features/oauth-consent.test.ts` + +Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. + +| Old assertion | New assertion | +| --------------------------------------------------------- | ----------------------------------------- | +| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | +| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | +| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | +| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | + +### `e2e/unauthenticated/oauth-consent.test.ts` + +The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. + +```ts +// Before +await expect(page.getByText('Error: No session found')).toBeVisible(); + +// After +await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); +``` + +--- + +## What Is Not Changing + +- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` +- `utils/devBrowser.ts` — stays, unrelated to OAuth consent +- The page URL (`/oauth-consent`) and its Next.js route — unchanged +- The referrer meta tag — kept +- CSS class names (`pageContainer`, `componentContainer`) — unchanged From d04f79e06537cc025bbe2d5074c4096b2d5b68d3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 14 Apr 2026 21:50:14 -0700 Subject: [PATCH 02/10] feat(ui): add infinite scroll org picker to OAuthConsent - Add `OrgSelect` component with infinite scroll support using `useInView` + `InfiniteListSpinner` - Pre-select the active org in the org picker dropdown - Refactor `SelectOptionList` to accept a generic `footer` slot instead of `hasMore`/`onLoadMore` props, keeping pagination concerns out of the shared primitive - Gate all org selection UI behind `ctx.enableOrgSelection` so the accounts portal path is unaffected - Include synthetic org data for manual testing until a real 10+ org account is available --- ...-accounts-oauth-consent-refactor-design.md | 88 ----------------- packages/clerk-js/sandbox/app.ts | 7 +- .../components/OAuthConsent/OAuthConsent.tsx | 30 +++++- .../src/components/OAuthConsent/OrgSelect.tsx | 16 ++- .../__tests__/OAuthConsent.test.tsx | 99 ++++++++++++++++++- packages/ui/src/elements/Select.tsx | 4 +- 6 files changed, 144 insertions(+), 100 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md deleted file mode 100644 index 212aab20054..00000000000 --- a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Accounts Portal OAuth Consent Refactor Design - -> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. - -**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. - -**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. - ---- - -## Files Deleted - -- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms -- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility -- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) - -## Files Modified - -### `types/index.ts` - -Remove the re-export of the deleted type file: - -```diff -- export * from './OAuthConsent'; - export * from './AccountPortalJSON'; - export * from './constants'; -``` - -`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. - -### `pages/oauth-consent/[[...index]].tsx` - -Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). - -```tsx -import React from 'react'; -import Head from 'next/head'; -import { OAuthConsent } from '@clerk/nextjs/internal'; - -export default function ConsentPage(): JSX.Element { - return ( -
-
- - - - -
-
- ); -} -``` - -### `e2e/features/oauth-consent.test.ts` - -Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. - -| Old assertion | New assertion | -| --------------------------------------------------------- | ----------------------------------------- | -| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | -| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | -| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | -| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | - -### `e2e/unauthenticated/oauth-consent.test.ts` - -The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. - -```ts -// Before -await expect(page.getByText('Error: No session found')).toBeVisible(); - -// After -await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); -``` - ---- - -## What Is Not Changing - -- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` -- `utils/devBrowser.ts` — stays, unrelated to OAuth consent -- The page URL (`/oauth-consent`) and its Next.js route — unchanged -- The referrer meta tag — kept -- CSS class names (`pageContainer`, `componentContainer`) — unchanged diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 064c740eb27..dd6c5f4db12 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,4 +1,5 @@ -import { PageMocking, type MockScenario } from '@clerk/msw'; +import { type MockScenario, PageMocking } from '@clerk/msw'; + import * as l from '../../localizations'; import { dark, neobrutalism, shadcn, shadesOfPurple } from '../../ui/src/themes'; import type { Clerk as ClerkType } from '../'; @@ -350,7 +351,9 @@ function themeSelector() { type Preset = { elements: Record; options?: Record; variables?: Record }; function presetToAppearance(preset: Preset | undefined) { - if (!preset) return {}; + if (!preset) { + return {}; + } return { elements: preset.elements, ...(preset.options ? { options: preset.options } : {}), diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 430d8779103..4eadd657b7e 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,4 +1,4 @@ -import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react'; +import { useClerk, useOAuthConsent, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react'; import { useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; @@ -37,6 +37,7 @@ function _OAuthConsent() { // TODO(rob): Implement lazy loading in another PR userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined, }); + const { organization: activeOrg } = useOrganization(); const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ value: m.organization.id, @@ -44,8 +45,27 @@ function _OAuthConsent() { logoUrl: m.organization.imageUrl, })); + // TEMP: Synthetic orgs for manual infinite-scroll testing. + // Remove in follow-up once testing with a real account that has 10+ orgs. + const [syntheticPage, setSyntheticPage] = useState(1); + const syntheticOrgs: OrgOption[] = ctx.enableOrgSelection + ? Array.from({ length: syntheticPage * 5 }, (_, i) => ({ + value: `synthetic_org_${i + 1}`, + label: `Synthetic Org ${i + 1}`, + logoUrl: orgOptions[0]?.logoUrl ?? '', + })) + : []; + const mergedOrgOptions = ctx.enableOrgSelection ? [...orgOptions, ...syntheticOrgs] : orgOptions; + const syntheticHasMore = ctx.enableOrgSelection && syntheticPage < 4; // 4 pages x 5 = 20 total + const syntheticFetchNext = () => setSyntheticPage(p => p + 1); + // TEMP END + const [selectedOrg, setSelectedOrg] = useState(null); - const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; + const effectiveOrg = + selectedOrg ?? + (activeOrg ? mergedOrgOptions.find(o => o.value === activeOrg.id)?.value : undefined) ?? + mergedOrgOptions[0]?.value ?? + null; // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -228,11 +248,13 @@ function _OAuthConsent() { })} /> - {ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && ( + {ctx.enableOrgSelection && mergedOrgOptions.length > 0 && effectiveOrg && ( )} diff --git a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx index aac8314e78b..4269b8a4cac 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 911ef36327f..df40701d3b3 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 { useOrganization, useOrganizationList } from '@clerk/shared/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useOrganizationList } from '@clerk/shared/react'; - 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,19 @@ 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 }, }), + useOrganization: vi.fn().mockReturnValue({ organization: null }), }; }); +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 +67,7 @@ describe('OAuthConsent', () => { const originalLocation = window.location; beforeEach(() => { + capturedLoadMoreOnChange = undefined; Object.defineProperty(window, 'location', { configurable: true, writable: true, @@ -431,4 +443,85 @@ describe('OAuthConsent', () => { }); }); }); + + describe('org selection — infinite scroll and active-org pre-selection', () => { + 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('wires the load-more sentinel to the onLoadMore callback', async () => { + 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: vi.fn(), isLoading: false }, + } as any); + + render(, { wrapper }); + + // The load-more sentinel is always wired up when enableOrgSelection is true + // (syntheticHasMore starts at true since syntheticPage=1 < 4) + await waitFor(() => { + expect(capturedLoadMoreOnChange).toBeDefined(); + }); + + // Calling it should not throw — it calls syntheticFetchNext which updates state + expect(() => capturedLoadMoreOnChange!(true)).not.toThrow(); + }); + + it('pre-selects the active organization when the session has one', async () => { + 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: vi.fn(), isLoading: false }, + } as any); + + // Active org is org_2 — second in list, not first, to prove ordering matters + vi.mocked(useOrganization).mockReturnValue({ organization: { id: 'org_2' } } as any); + + const { baseElement } = render(, { wrapper }); + + await waitFor(() => { + const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!; + const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null; + expect(hiddenInput?.value).toBe('org_2'); + }); + }); + + it('falls back to the first org when the session has no active organization', async () => { + 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: vi.fn(), isLoading: false }, + } as any); + + vi.mocked(useOrganization).mockReturnValue({ organization: null } as any); + + const { baseElement } = render(, { wrapper }); + + await waitFor(() => { + const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!; + const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null; + expect(hiddenInput?.value).toBe('org_1'); + }); + }); + }); }); diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx index 3a32ca5e9d5..df0504582dc 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -235,10 +235,11 @@ export const SelectNoResults = (props: PropsOfComponent) => { type SelectOptionListProps = PropsOfComponent & { containerSx?: ThemableCssProp; + footer?: React.ReactNode; }; export const SelectOptionList = (props: SelectOptionListProps) => { - const { containerSx, sx, ...rest } = props; + const { containerSx, sx, footer, ...rest } = props; const { popoverCtx, searchInputCtx, @@ -376,6 +377,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => { ); })} {noResultsMessage && options.length === 0 && {noResultsMessage}} + {footer} From dbcca69a23107b0099670277742ebc1600ece671 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 15 Apr 2026 16:43:36 -0700 Subject: [PATCH 03/10] chore: remove test data --- .../components/OAuthConsent/OAuthConsent.tsx | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 09349104e1c..ca10dc52a09 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,4 +1,4 @@ -import { useClerk, useOAuthConsent, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react'; +import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react'; import { useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; @@ -38,35 +38,14 @@ function _OAuthConsent() { // TODO(rob): Implement lazy loading in another PR userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined, }); - const { organization: activeOrg } = useOrganization(); - const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ value: m.organization.id, label: m.organization.name, logoUrl: m.organization.imageUrl, })); - // TEMP: Synthetic orgs for manual infinite-scroll testing. - // Remove in follow-up once testing with a real account that has 10+ orgs. - const [syntheticPage, setSyntheticPage] = useState(1); - const syntheticOrgs: OrgOption[] = ctx.enableOrgSelection - ? Array.from({ length: syntheticPage * 5 }, (_, i) => ({ - value: `synthetic_org_${i + 1}`, - label: `Synthetic Org ${i + 1}`, - logoUrl: orgOptions[0]?.logoUrl ?? '', - })) - : []; - const mergedOrgOptions = ctx.enableOrgSelection ? [...orgOptions, ...syntheticOrgs] : orgOptions; - const syntheticHasMore = ctx.enableOrgSelection && syntheticPage < 4; // 4 pages x 5 = 20 total - const syntheticFetchNext = () => setSyntheticPage(p => p + 1); - // TEMP END - const [selectedOrg, setSelectedOrg] = useState(null); - const effectiveOrg = - selectedOrg ?? - (activeOrg ? mergedOrgOptions.find(o => o.value === activeOrg.id)?.value : undefined) ?? - mergedOrgOptions[0]?.value ?? - null; + const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -243,13 +222,13 @@ function _OAuthConsent() { })} /> - {ctx.enableOrgSelection && mergedOrgOptions.length > 0 && effectiveOrg && ( + {ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && ( )} From 36bdbad1f5e0e1843d852e67d376d707b8c1870b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 15 Apr 2026 16:44:58 -0700 Subject: [PATCH 04/10] chore: set default orgs to 10 --- packages/ui/src/components/OAuthConsent/OAuthConsent.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index ca10dc52a09..c71d2d06bb6 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -35,8 +35,7 @@ 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, From 4852c74f89da776820ed148704d1c2c3f594df27 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 15 Apr 2026 16:58:19 -0700 Subject: [PATCH 05/10] chore: make arrow scrolling with infinite data work --- packages/ui/src/components/OAuthConsent/OrgSelect.tsx | 5 ++++- packages/ui/src/elements/Select.tsx | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx index 4269b8a4cac..5579f3c3de0 100644 --- a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx +++ b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx @@ -113,7 +113,10 @@ export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: Org {selected?.label || 'Select an option'} - : null} /> + : null} + onReachEnd={hasMore ? onLoadMore : undefined} + /> ); } diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx index df0504582dc..0013611ae01 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -236,10 +236,11 @@ export const SelectNoResults = (props: PropsOfComponent) => { type SelectOptionListProps = PropsOfComponent & { containerSx?: ThemableCssProp; footer?: React.ReactNode; + onReachEnd?: () => void; }; export const SelectOptionList = (props: SelectOptionListProps) => { - const { containerSx, sx, footer, ...rest } = props; + const { containerSx, sx, footer, onReachEnd, ...rest } = props; const { popoverCtx, searchInputCtx, @@ -295,7 +296,11 @@ export const SelectOptionList = (props: SelectOptionListProps) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (isOpen) { - return setFocusedIndex((i = 0) => (i === options.length - 1 ? 0 : i + 1)); + if (focusedIndex === options.length - 1) { + onReachEnd?.(); + return; + } + return setFocusedIndex((i = 0) => i + 1); } return onTriggerClick(); } From 4ff149cbf1bad1b7da2692d3a7cf6fc936ae0ea0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 15 Apr 2026 18:24:47 -0700 Subject: [PATCH 06/10] remove unused test --- .../__tests__/OAuthConsent.test.tsx | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index d1fb781334c..8ba54eb3f52 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -475,31 +475,6 @@ describe('OAuthConsent', () => { expect(() => capturedLoadMoreOnChange!(true)).not.toThrow(); }); - it('pre-selects the active organization when the session has one', async () => { - 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: vi.fn(), isLoading: false }, - } as any); - - // Active org is org_2 — second in list, not first, to prove ordering matters - vi.mocked(useOrganization).mockReturnValue({ organization: { id: 'org_2' } } as any); - - const { baseElement } = render(, { wrapper }); - - await waitFor(() => { - const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!; - const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null; - expect(hiddenInput?.value).toBe('org_2'); - }); - }); - it('falls back to the first org when the session has no active organization', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); From 2db97310690bce3ce58b963aaf127e1f5eb25e77 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 16 Apr 2026 08:21:59 -0700 Subject: [PATCH 07/10] chore: add changeset --- .changeset/eleven-mugs-buy.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/eleven-mugs-buy.md diff --git a/.changeset/eleven-mugs-buy.md b/.changeset/eleven-mugs-buy.md new file mode 100644 index 00000000000..276606d0f61 --- /dev/null +++ b/.changeset/eleven-mugs-buy.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/ui": patch +--- + +Add infinite loading to organization selection in `` From 228431373ff15f69511607e16342908d6a0b6e06 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 16 Apr 2026 08:25:00 -0700 Subject: [PATCH 08/10] chore: clean up test description --- .../src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index 8ba54eb3f52..3ddc03bdd52 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -444,7 +444,7 @@ describe('OAuthConsent', () => { }); }); - describe('org selection — infinite scroll and active-org pre-selection', () => { + 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' } }, From df1f55e18eb6b2fb26dfe17d70cd85d913200fc5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 16 Apr 2026 09:38:33 -0700 Subject: [PATCH 09/10] chore: fix arrow down regression --- packages/clerk-js/sandbox/app.ts | 7 ++-- .../__tests__/OAuthConsent.test.tsx | 35 ++++++++----------- packages/ui/src/elements/Select.tsx | 6 ++-- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index dd6c5f4db12..064c740eb27 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,4 @@ -import { type MockScenario, PageMocking } from '@clerk/msw'; - +import { PageMocking, type MockScenario } from '@clerk/msw'; import * as l from '../../localizations'; import { dark, neobrutalism, shadcn, shadesOfPurple } from '../../ui/src/themes'; import type { Clerk as ClerkType } from '../'; @@ -351,9 +350,7 @@ function themeSelector() { type Preset = { elements: Record; options?: Record; variables?: Record }; function presetToAppearance(preset: Preset | undefined) { - if (!preset) { - return {}; - } + if (!preset) return {}; return { elements: preset.elements, ...(preset.options ? { options: preset.options } : {}), diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index 3ddc03bdd52..e871fd41fb1 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -1,4 +1,4 @@ -import { useOrganization, useOrganizationList } from '@clerk/shared/react'; +import { useOrganizationList } from '@clerk/shared/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; @@ -20,7 +20,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { isLoaded: false, userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: false }, }), - useOrganization: vi.fn().mockReturnValue({ organization: null }), }; }); @@ -450,7 +449,8 @@ describe('OAuthConsent', () => { { organization: { id: 'org_2', name: 'Beta Inc', imageUrl: 'https://img.clerk.com/static/beta.png' } }, ]; - it('wires the load-more sentinel to the onLoadMore callback', async () => { + 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'] }); }); @@ -460,22 +460,19 @@ describe('OAuthConsent', () => { vi.mocked(useOrganizationList).mockReturnValue({ isLoaded: true, - userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext: vi.fn(), isLoading: false }, + userMemberships: { data: twoOrgs, hasNextPage: true, fetchNext, isLoading: false }, } as any); render(, { wrapper }); - // The load-more sentinel is always wired up when enableOrgSelection is true - // (syntheticHasMore starts at true since syntheticPage=1 < 4) - await waitFor(() => { - expect(capturedLoadMoreOnChange).toBeDefined(); - }); + await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined()); - // Calling it should not throw — it calls syntheticFetchNext which updates state - expect(() => capturedLoadMoreOnChange!(true)).not.toThrow(); + capturedLoadMoreOnChange!(true); + expect(fetchNext).toHaveBeenCalledTimes(1); }); - it('falls back to the first org when the session has no active organization', async () => { + 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'] }); }); @@ -485,18 +482,14 @@ describe('OAuthConsent', () => { vi.mocked(useOrganizationList).mockReturnValue({ isLoaded: true, - userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext: vi.fn(), isLoading: false }, + userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext, isLoading: false }, } as any); - vi.mocked(useOrganization).mockReturnValue({ organization: null } as any); - - const { baseElement } = render(, { wrapper }); + render(, { wrapper }); - await waitFor(() => { - const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!; - const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null; - expect(hiddenInput?.value).toBe('org_1'); - }); + 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 0013611ae01..1dfb229f32c 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -296,11 +296,11 @@ export const SelectOptionList = (props: SelectOptionListProps) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (isOpen) { - if (focusedIndex === options.length - 1) { - onReachEnd?.(); + if (onReachEnd && focusedIndex === options.length - 1) { + onReachEnd(); return; } - return setFocusedIndex((i = 0) => i + 1); + return setFocusedIndex((i = 0) => (i === options.length - 1 ? 0 : i + 1)); } return onTriggerClick(); } From 56d843190d42342747a015f4e53248daf1d4fff5 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 16 Apr 2026 09:42:47 -0700 Subject: [PATCH 10/10] chore: update changeset --- .changeset/eleven-mugs-buy.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/eleven-mugs-buy.md b/.changeset/eleven-mugs-buy.md index 276606d0f61..93b8d402377 100644 --- a/.changeset/eleven-mugs-buy.md +++ b/.changeset/eleven-mugs-buy.md @@ -1,6 +1,5 @@ --- -"@clerk/clerk-js": patch "@clerk/ui": patch --- -Add infinite loading to organization selection in `` +Add infinite loading to organization selection in ``.