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/RemoveDomainForm.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainForm.tsx index 9aabea7319b..16aefca0f54 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainForm.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainForm.tsx @@ -19,7 +19,7 @@ export const RemoveDomainForm = (props: RemoveDomainFormProps) => { const { domainId: id, onSuccess, onReset } = props; const ref = React.useRef(); - const { data: domain, status: domainStatus } = useFetch( + const { data: domain, isLoading: domainIsLoading } = useFetch( organization?.getDomain, { domainId: id, @@ -41,7 +41,7 @@ export const RemoveDomainForm = (props: RemoveDomainFormProps) => { return null; } - if (domainStatus.isLoading || !domain) { + if (domainIsLoading || !domain) { return ( diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainForm.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainForm.tsx index c8b4f9067b5..c71fbb340ea 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainForm.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainForm.tsx @@ -29,7 +29,7 @@ export const VerifyDomainForm = withCardStateProvider((props: VerifyDomainFormPr const { organizationSettings } = useEnvironment(); const { organization } = useOrganization(); - const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, { + const { data: domain, isLoading: domainIsLoading } = useFetch(organization?.getDomain, { domainId: id, }); const title = localizationKeys('organizationProfile.verifyDomainPage.title'); @@ -108,7 +108,7 @@ export const VerifyDomainForm = withCardStateProvider((props: VerifyDomainFormPr }); }; - 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 f8074feecef..7059868d528 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -146,6 +146,7 @@ export const OrganizationSwitcherPopover = React.forwardRef ({ padding: `${t.space.$4} ${t.space.$5}`, @@ -178,6 +179,7 @@ export const OrganizationSwitcherPopover = React.forwardRef ({ padding: `${t.space.$4} ${t.space.$5}`, diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx index f4d85a8e967..d15a4946df7 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={'organizationSwitcherTrigger'} gap={3} size='xs' + fetchRoles organization={organization} sx={t => ({ maxWidth: '30ch', color: t.colors.$blackAlpha600 })} /> 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 d85f2195b6f..0cf7cfa5ffc 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 @@ -56,6 +56,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 d00c5988c59..9d91fa0f782 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'> & { @@ -17,6 +17,7 @@ export type OrganizationPreviewProps = Omit, 'elem badge?: React.ReactNode; rounded?: boolean; elementId?: OrganizationPreviewId; + fetchRoles?: boolean; }; export const OrganizationPreview = (props: OrganizationPreviewProps) => { @@ -25,6 +26,7 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => { size = 'md', icon, rounded = false, + fetchRoles = false, badge, sx, user, @@ -34,7 +36,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; const mainTextSize = mainIdentifierVariant || ({ xs: 'subtitle', sm: 'caption', md: 'subtitle', lg: 'h1' } as const)[size]; @@ -84,7 +91,7 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => { diff --git a/packages/clerk-js/src/ui/hooks/useFetch.ts b/packages/clerk-js/src/ui/hooks/useFetch.ts index 87c81d43477..99e3b23028b 100644 --- a/packages/clerk-js/src/ui/hooks/useFetch.ts +++ b/packages/clerk-js/src/ui/hooks/useFetch.ts @@ -1,44 +1,119 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'; -import { useLoadingStatus } from './useLoadingStatus'; -import { useSafeState } from './useSafeState'; +export type State = { + 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 b685c4337d8..4ec4830e379 100644 --- a/packages/clerk-js/src/ui/hooks/useFetchRoles.ts +++ b/packages/clerk-js/src/ui/hooks/useFetchRoles.ts @@ -11,12 +11,12 @@ const getRolesParams = { */ pageSize: 20, }; -export const useFetchRoles = () => { +export const useFetchRoles = (enabled = true) => { const { organization } = useOrganization(); - 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 })), }; };