Skip to content

Commit 741546b

Browse files
committed
fix(clerk-js): Fetching custom role in OrganizationSwitcher with cache (#2430)
1 parent 3ec07c1 commit 741546b

File tree

11 files changed

+134
-36
lines changed

11 files changed

+134
-36
lines changed

.changeset/two-crews-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Bug fix: fetch custom roles in OrganizationSwitcher

packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const RemoveDomainPage = () => {
1515
const { params } = useRouter();
1616

1717
const ref = React.useRef<OrganizationDomainResource>();
18-
const { data: domain, status: domainStatus } = useFetch(
18+
const { data: domain, isLoading: domainIsLoading } = useFetch(
1919
organization?.getDomain,
2020
{
2121
domainId: params.id,
@@ -37,7 +37,7 @@ export const RemoveDomainPage = () => {
3737
return null;
3838
}
3939

40-
if (domainStatus.isLoading || !domain) {
40+
if (domainIsLoading || !domain) {
4141
return (
4242
<Flex
4343
direction={'row'}

packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { LinkButtonWithDescription } from '../UserProfile/LinkButtonWithDescript
2525
import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';
2626

2727
const useCalloutLabel = (
28-
domain: OrganizationDomainResource | null,
28+
domain: OrganizationDomainResource | undefined | null,
2929
{
3030
infoLabel: infoLabelKey,
3131
}: {
@@ -120,7 +120,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
120120
type: 'checkbox',
121121
});
122122

123-
const { data: domain, status: domainStatus } = useFetch(
123+
const { data: domain, isLoading: domainIsLoading } = useFetch(
124124
organization?.getDomain,
125125
{
126126
domainId: params.id,
@@ -168,7 +168,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
168168
return null;
169169
}
170170

171-
if (domainStatus.isLoading || !domain) {
171+
if (domainIsLoading || !domain) {
172172
return (
173173
<Flex
174174
direction={'row'}
@@ -264,7 +264,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
264264
localizationKey={localizationKeys(
265265
'organizationProfile.verifiedDomainPage.enrollmentTab.formButton__save',
266266
)}
267-
isDisabled={domainStatus.isLoading || !domain || !isFormDirty}
267+
isDisabled={domainIsLoading || !domain || !isFormDirty}
268268
/>
269269
</Form.Root>
270270
</TabPanel>

packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const VerifyDomainPage = withCardStateProvider(() => {
2727

2828
const [success, setSuccess] = React.useState(false);
2929

30-
const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, {
30+
const { data: domain, isLoading: domainIsLoading } = useFetch(organization?.getDomain, {
3131
domainId: params.id,
3232
});
3333
const title = localizationKeys('organizationProfile.verifyDomainPage.title');
@@ -122,7 +122,7 @@ export const VerifyDomainPage = withCardStateProvider(() => {
122122
});
123123
};
124124

125-
if (domainStatus.isLoading || !domain) {
125+
if (domainIsLoading || !domain) {
126126
return (
127127
<Flex
128128
direction={'row'}

packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { act, waitFor } from '@testing-library/react';
44
import userEvent from '@testing-library/user-event';
55

66
import { render } from '../../../../testUtils';
7+
import { clearFetchCache } from '../../../hooks/useFetch';
78
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
89
import { runFakeTimers } from '../../../utils/test/runFakeTimers';
910
import { OrganizationMembers } from '../OrganizationMembers';
@@ -12,6 +13,13 @@ import { createFakeMember, createFakeOrganizationInvitation, createFakeOrganizat
1213
const { createFixtures } = bindCreateFixtures('OrganizationProfile');
1314

1415
describe('OrganizationMembers', () => {
16+
/**
17+
* `<OrganizationMembers/>` internally uses useFetch which caches the results, be sure to clear the cache before each test
18+
*/
19+
beforeEach(() => {
20+
clearFetchCache();
21+
});
22+
1523
it('renders the Organization Members page', async () => {
1624
const { wrapper, fixtures } = await createFixtures(f => {
1725
f.withOrganizations();

packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
145145
gap={4}
146146
organization={currentOrg}
147147
user={user}
148+
fetchRoles
148149
sx={theme => t => ({ padding: `0 ${theme.space.$6}`, marginBottom: t.space.$2 })}
149150
/>
150151
<Actions role='menu'>

packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
4343
elementId={'organizationSwitcher'}
4444
gap={3}
4545
size={'sm'}
46+
fetchRoles
4647
organization={organization}
4748
sx={{ maxWidth: '30ch' }}
4849
/>

packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('OrganizationSwitcher', () => {
5252
});
5353
});
5454

55+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
5556
fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce(
5657
Promise.resolve({
5758
data: [],

packages/clerk-js/src/ui/elements/OrganizationPreview.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { OrganizationPreviewId, UserOrganizationInvitationResource, UserRes
22
import React from 'react';
33

44
import { descriptors, Flex, Text } from '../customizables';
5+
import { useFetchRoles, useLocalizeCustomRoles } from '../hooks/useFetchRoles';
56
import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
6-
import { roleLocalizationKey } from '../utils';
77
import { OrganizationAvatar } from './OrganizationAvatar';
88

99
export type OrganizationPreviewProps = Omit<PropsOfComponent<typeof Flex>, 'elementId'> & {
@@ -16,6 +16,7 @@ export type OrganizationPreviewProps = Omit<PropsOfComponent<typeof Flex>, 'elem
1616
badge?: React.ReactNode;
1717
rounded?: boolean;
1818
elementId?: OrganizationPreviewId;
19+
fetchRoles?: boolean;
1920
};
2021

2122
export const OrganizationPreview = (props: OrganizationPreviewProps) => {
@@ -24,6 +25,7 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => {
2425
size = 'md',
2526
icon,
2627
rounded = false,
28+
fetchRoles = false,
2729
badge,
2830
sx,
2931
user,
@@ -32,7 +34,12 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => {
3234
elementId,
3335
...rest
3436
} = props;
35-
const role = user?.organizationMemberships.find(membership => membership.organization.id === organization.id)?.role;
37+
38+
const { localizeCustomRole } = useLocalizeCustomRoles();
39+
const { options } = useFetchRoles(fetchRoles);
40+
41+
const membership = user?.organizationMemberships.find(membership => membership.organization.id === organization.id);
42+
const unlocalizedRoleLabel = options?.find(a => a.value === membership?.role)?.label;
3643

3744
return (
3845
<Flex
@@ -80,7 +87,7 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => {
8087
<Text
8188
elementDescriptor={descriptors.organizationPreviewSecondaryIdentifier}
8289
elementId={descriptors.organizationPreviewSecondaryIdentifier.setId(elementId)}
83-
localizationKey={role && roleLocalizationKey(role)}
90+
localizationKey={localizeCustomRole(membership?.role) || unlocalizedRoleLabel}
8491
variant='smallRegular'
8592
colorScheme='neutral'
8693
truncate
Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,119 @@
1-
import { useEffect, useRef } from 'react';
1+
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
22

3-
import { useLoadingStatus } from './useLoadingStatus';
4-
import { useSafeState } from './useSafeState';
3+
export type State<Data = any, Error = any> = {
4+
data: Data | null;
5+
error: Error | null;
6+
/**
7+
* if there's an ongoing request and no "loaded data"
8+
*/
9+
isLoading: boolean;
10+
/**
11+
* if there's a request or revalidation loading
12+
*/
13+
isValidating: boolean;
14+
cachedAt?: number;
15+
};
16+
17+
/**
18+
* Global cache for storing status of fetched resources
19+
*/
20+
let requestCache = new Map<string, State>();
21+
22+
/**
23+
* A set to store subscribers in order to notify when the value of a key of `requestCache` changes
24+
*/
25+
const subscribers = new Set<() => void>();
26+
27+
/**
28+
* This utility should only be used in tests to clear previously fetched data
29+
*/
30+
export const clearFetchCache = () => {
31+
requestCache = new Map<string, State>();
32+
};
33+
34+
const serialize = (obj: unknown) => JSON.stringify(obj);
535

6-
export const useFetch = <T>(
36+
const useCache = <K = any, V = any>(
37+
key: K,
38+
): {
39+
getCache: () => State<V> | undefined;
40+
setCache: (state: State) => void;
41+
subscribeCache: (callback: () => void) => () => void;
42+
} => {
43+
const serializedKey = serialize(key);
44+
const get = useCallback(() => requestCache.get(serializedKey), [serializedKey]);
45+
const set = useCallback(
46+
(data: State) => {
47+
requestCache.set(serializedKey, data);
48+
subscribers.forEach(callback => callback());
49+
},
50+
[serializedKey],
51+
);
52+
const subscribe = useCallback((callback: () => void) => {
53+
subscribers.add(callback);
54+
return () => subscribers.delete(callback);
55+
}, []);
56+
57+
return {
58+
getCache: get,
59+
setCache: set,
60+
subscribeCache: subscribe,
61+
};
62+
};
63+
64+
export const useFetch = <K, T>(
765
fetcher: ((...args: any) => Promise<T>) | undefined,
8-
params: any,
66+
params: K,
967
callbacks?: {
1068
onSuccess?: (data: T) => void;
1169
},
1270
) => {
13-
const [data, setData] = useSafeState<T | null>(null);
14-
const requestStatus = useLoadingStatus({
15-
status: 'loading',
16-
});
17-
71+
const { subscribeCache, getCache, setCache } = useCache<K, T>(params);
1872
const fetcherRef = useRef(fetcher);
1973

74+
const cached = useSyncExternalStore(subscribeCache, getCache);
75+
2076
useEffect(() => {
21-
if (!fetcherRef.current) {
77+
const fetcherMissing = !fetcherRef.current;
78+
const isCacheStale = Date.now() - (getCache()?.cachedAt || 0) < 1000 * 60 * 2; //cache for 2 minutes;
79+
const isRequestOnGoing = getCache()?.isValidating;
80+
81+
if (fetcherMissing || isCacheStale || isRequestOnGoing) {
2282
return;
2383
}
24-
requestStatus.setLoading();
25-
fetcherRef
26-
.current(params)
84+
85+
setCache({
86+
data: null,
87+
isLoading: !getCache(),
88+
isValidating: true,
89+
error: null,
90+
});
91+
fetcherRef.current!(params)
2792
.then(result => {
28-
requestStatus.setIdle();
2993
if (typeof result !== 'undefined') {
30-
setData(typeof result === 'object' ? { ...result } : result);
31-
callbacks?.onSuccess?.(typeof result === 'object' ? { ...result } : result);
94+
const data = typeof result === 'object' ? { ...result } : result;
95+
setCache({
96+
data,
97+
isLoading: false,
98+
isValidating: false,
99+
error: null,
100+
cachedAt: Date.now(),
101+
});
102+
callbacks?.onSuccess?.(data);
32103
}
33104
})
34105
.catch(() => {
35-
requestStatus.setError();
36-
setData(null);
106+
setCache({
107+
data: null,
108+
isLoading: false,
109+
isValidating: false,
110+
error: true,
111+
cachedAt: Date.now(),
112+
});
37113
});
38-
}, [JSON.stringify(params)]);
114+
}, [serialize(params), setCache, getCache]);
39115

40116
return {
41-
status: requestStatus,
42-
data,
117+
...cached,
43118
};
44119
};

packages/clerk-js/src/ui/hooks/useFetchRoles.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ const getRolesParams = {
1010
*/
1111
pageSize: 20,
1212
};
13-
export const useFetchRoles = () => {
13+
export const useFetchRoles = (enabled = true) => {
1414
const { organization } = useCoreOrganization();
15-
const { data, status } = useFetch(organization?.getRoles, getRolesParams);
15+
const { data, isLoading } = useFetch(enabled ? organization?.getRoles : undefined, getRolesParams);
1616

1717
return {
18-
isLoading: status.isLoading,
18+
isLoading,
1919
options: data?.data?.map(role => ({ value: role.key, label: role.name })),
2020
};
2121
};

0 commit comments

Comments
 (0)