diff --git a/.changeset/two-crews-talk.md b/.changeset/two-crews-talk.md new file mode 100644 index 00000000000..c2181e0dda4 --- /dev/null +++ b/.changeset/two-crews-talk.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Bug fix: fetch custom roles in OrganizationSwitcher diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx index 4da8c1a2c03..327fcd15aa0 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx @@ -15,7 +15,7 @@ export const RemoveDomainPage = () => { const { params } = useRouter(); const ref = React.useRef(); - const { data: domain, status: domainStatus } = useFetch( + const { data: domain, isLoading: domainIsLoading } = useFetch( organization?.getDomain, { domainId: params.id, @@ -37,7 +37,7 @@ export const RemoveDomainPage = () => { return null; } - if (domainStatus.isLoading || !domain) { + if (domainIsLoading || !domain) { return ( { type: 'checkbox', }); - const { data: domain, status: domainStatus } = useFetch( + const { data: domain, isLoading: domainIsLoading } = useFetch( organization?.getDomain, { domainId: params.id, @@ -168,7 +168,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => { return null; } - if (domainStatus.isLoading || !domain) { + if (domainIsLoading || !domain) { return ( { localizationKey={localizationKeys( 'organizationProfile.verifiedDomainPage.enrollmentTab.formButton__save', )} - isDisabled={domainStatus.isLoading || !domain || !isFormDirty} + isDisabled={domainIsLoading || !domain || !isFormDirty} /> diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx index 7409988c3d4..c4656beb1b8 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx @@ -27,7 +27,7 @@ export const VerifyDomainPage = withCardStateProvider(() => { const [success, setSuccess] = React.useState(false); - const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, { + const { data: domain, isLoading: domainIsLoading } = useFetch(organization?.getDomain, { domainId: params.id, }); const title = localizationKeys('organizationProfile.verifyDomainPage.title'); @@ -122,7 +122,7 @@ export const VerifyDomainPage = withCardStateProvider(() => { }); }; - if (domainStatus.isLoading || !domain) { + if (domainIsLoading || !domain) { return ( { + /** + * `` internally uses useFetch which caches the results, be sure to clear the cache before each test + */ + beforeEach(() => { + clearFetchCache(); + }); + it('renders the Organization Members page', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withOrganizations(); diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index f13dd845c9f..d96d3ccaa8a 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -145,6 +145,7 @@ export const OrganizationSwitcherPopover = React.forwardRef t => ({ padding: `0 ${theme.space.$6}`, marginBottom: t.space.$2 })} /> diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx index 1d4d070c9be..8c8a753e49c 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx @@ -43,6 +43,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer( elementId={'organizationSwitcher'} gap={3} size={'sm'} + fetchRoles organization={organization} sx={{ maxWidth: '30ch' }} /> diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 41e7435f64b..d3baef76a42 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -52,6 +52,7 @@ describe('OrganizationSwitcher', () => { }); }); + fixtures.clerk.organization?.getRoles.mockRejectedValue(null); fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( Promise.resolve({ data: [], diff --git a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx index 11a3f1e5ec4..0bf0b6d343c 100644 --- a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx +++ b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx @@ -2,8 +2,8 @@ import type { OrganizationPreviewId, UserOrganizationInvitationResource, UserRes import React from 'react'; import { descriptors, Flex, Text } from '../customizables'; +import { useFetchRoles, useLocalizeCustomRoles } from '../hooks/useFetchRoles'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; -import { roleLocalizationKey } from '../utils'; import { OrganizationAvatar } from './OrganizationAvatar'; export type OrganizationPreviewProps = Omit, 'elementId'> & { @@ -16,6 +16,7 @@ export type OrganizationPreviewProps = Omit, 'elem badge?: React.ReactNode; rounded?: boolean; elementId?: OrganizationPreviewId; + fetchRoles?: boolean; }; export const OrganizationPreview = (props: OrganizationPreviewProps) => { @@ -24,6 +25,7 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => { size = 'md', icon, rounded = false, + fetchRoles = false, badge, sx, user, @@ -32,7 +34,12 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => { elementId, ...rest } = props; - const role = user?.organizationMemberships.find(membership => membership.organization.id === organization.id)?.role; + + const { localizeCustomRole } = useLocalizeCustomRoles(); + const { options } = useFetchRoles(fetchRoles); + + const membership = user?.organizationMemberships.find(membership => membership.organization.id === organization.id); + const unlocalizedRoleLabel = options?.find(a => a.value === membership?.role)?.label; return ( { = { + data: Data | null; + error: Error | null; + /** + * if there's an ongoing request and no "loaded data" + */ + isLoading: boolean; + /** + * if there's a request or revalidation loading + */ + isValidating: boolean; + cachedAt?: number; +}; + +/** + * Global cache for storing status of fetched resources + */ +let requestCache = new Map(); + +/** + * A set to store subscribers in order to notify when the value of a key of `requestCache` changes + */ +const subscribers = new Set<() => void>(); + +/** + * This utility should only be used in tests to clear previously fetched data + */ +export const clearFetchCache = () => { + requestCache = new Map(); +}; + +const serialize = (obj: unknown) => JSON.stringify(obj); -export const useFetch = ( +const useCache = ( + key: K, +): { + getCache: () => State | undefined; + setCache: (state: State) => void; + subscribeCache: (callback: () => void) => () => void; +} => { + const serializedKey = serialize(key); + const get = useCallback(() => requestCache.get(serializedKey), [serializedKey]); + const set = useCallback( + (data: State) => { + requestCache.set(serializedKey, data); + subscribers.forEach(callback => callback()); + }, + [serializedKey], + ); + const subscribe = useCallback((callback: () => void) => { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, []); + + return { + getCache: get, + setCache: set, + subscribeCache: subscribe, + }; +}; + +export const useFetch = ( fetcher: ((...args: any) => Promise) | undefined, - params: any, + params: K, callbacks?: { onSuccess?: (data: T) => void; }, ) => { - const [data, setData] = useSafeState(null); - const requestStatus = useLoadingStatus({ - status: 'loading', - }); - + const { subscribeCache, getCache, setCache } = useCache(params); const fetcherRef = useRef(fetcher); + const cached = useSyncExternalStore(subscribeCache, getCache); + useEffect(() => { - if (!fetcherRef.current) { + const fetcherMissing = !fetcherRef.current; + const isCacheStale = Date.now() - (getCache()?.cachedAt || 0) < 1000 * 60 * 2; //cache for 2 minutes; + const isRequestOnGoing = getCache()?.isValidating; + + if (fetcherMissing || isCacheStale || isRequestOnGoing) { return; } - requestStatus.setLoading(); - fetcherRef - .current(params) + + setCache({ + data: null, + isLoading: !getCache(), + isValidating: true, + error: null, + }); + fetcherRef.current!(params) .then(result => { - requestStatus.setIdle(); if (typeof result !== 'undefined') { - setData(typeof result === 'object' ? { ...result } : result); - callbacks?.onSuccess?.(typeof result === 'object' ? { ...result } : result); + const data = typeof result === 'object' ? { ...result } : result; + setCache({ + data, + isLoading: false, + isValidating: false, + error: null, + cachedAt: Date.now(), + }); + callbacks?.onSuccess?.(data); } }) .catch(() => { - requestStatus.setError(); - setData(null); + setCache({ + data: null, + isLoading: false, + isValidating: false, + error: true, + cachedAt: Date.now(), + }); }); - }, [JSON.stringify(params)]); + }, [serialize(params), setCache, getCache]); return { - status: requestStatus, - data, + ...cached, }; }; diff --git a/packages/clerk-js/src/ui/hooks/useFetchRoles.ts b/packages/clerk-js/src/ui/hooks/useFetchRoles.ts index cbe0f32499f..a6f258c7ab2 100644 --- a/packages/clerk-js/src/ui/hooks/useFetchRoles.ts +++ b/packages/clerk-js/src/ui/hooks/useFetchRoles.ts @@ -10,12 +10,12 @@ const getRolesParams = { */ pageSize: 20, }; -export const useFetchRoles = () => { +export const useFetchRoles = (enabled = true) => { const { organization } = useCoreOrganization(); - const { data, status } = useFetch(organization?.getRoles, getRolesParams); + const { data, isLoading } = useFetch(enabled ? organization?.getRoles : undefined, getRolesParams); return { - isLoading: status.isLoading, + isLoading, options: data?.data?.map(role => ({ value: role.key, label: role.name })), }; };