diff --git a/.changeset/blue-ghosts-float.md b/.changeset/blue-ghosts-float.md new file mode 100644 index 00000000000..de8929d4532 --- /dev/null +++ b/.changeset/blue-ghosts-float.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +--- + +Expose `revalidate` and `setData` for paginated lists of data in organization hooks. +`const {userMemberships:{revalidate, setData}} = useOrganizationList({userMemberships:true})` diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserInvitationList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserInvitationList.tsx index c32931a39a0..5fc91c5f526 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserInvitationList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserInvitationList.tsx @@ -5,7 +5,7 @@ import { useCoreClerk, useCoreOrganizationList } from '../../contexts'; import { localizationKeys } from '../../customizables'; import { useCardState, withCardStateProvider } from '../../elements'; import { handleError } from '../../utils'; -import { updateCacheInPlace } from '../OrganizationSwitcher/utils'; +import { populateCacheUpdateItem } from '../OrganizationSwitcher/utils'; import { PreviewListItem, PreviewListItemButton } from './shared'; import { MembershipPreview } from './UserMembershipList'; import { organizationListParams } from './utils'; @@ -39,7 +39,7 @@ export const InvitationPreview = withCardStateProvider((props: UserOrganizationI }) .then(([updatedItem, organization]) => { // Update cache in case another listener depends on it - updateCacheInPlace(userInvitations)(updatedItem); + void userInvitations?.setData?.(cachedPages => populateCacheUpdateItem(updatedItem, cachedPages)); setAcceptedOrganization(organization); }) .catch(err => handleError(err, [], card.setError)); diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserSuggestionList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserSuggestionList.tsx index 7536a87ca19..3c8af375e7e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserSuggestionList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserSuggestionList.tsx @@ -4,7 +4,7 @@ import { useCoreOrganizationList } from '../../contexts'; import { localizationKeys, Text } from '../../customizables'; import { useCardState, withCardStateProvider } from '../../elements'; import { handleError } from '../../utils'; -import { updateCacheInPlace } from '../OrganizationSwitcher/utils'; +import { populateCacheUpdateItem } from '../OrganizationSwitcher/utils'; import { PreviewListItem, PreviewListItemButton } from './shared'; import { organizationListParams } from './utils'; @@ -17,7 +17,7 @@ export const AcceptRejectInvitationButtons = (props: OrganizationSuggestionResou const handleAccept = () => { return card .runAsync(props.accept) - .then(updateCacheInPlace(userSuggestions)) + .then(updatedItem => userSuggestions?.setData?.(pages => populateCacheUpdateItem(updatedItem, pages))) .catch(err => handleError(err, [], card.setError)); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx index 66f12915354..141a49b99b8 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx @@ -9,17 +9,10 @@ import { DataTable, RoleSelect, RowContainer } from './MemberListTable'; export const ActiveMembersList = () => { const card = useCardState(); - const { organization, memberships, ...rest } = useCoreOrganization({ + const { organization, memberships } = useCoreOrganization({ memberships: true, }); - const mutateSwrState = () => { - const unstable__mutate = (rest as any).unstable__mutate; - if (unstable__mutate && typeof unstable__mutate === 'function') { - unstable__mutate(); - } - }; - if (!organization) { return null; } @@ -27,7 +20,7 @@ export const ActiveMembersList = () => { const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: MembershipRole) => { return card .runAsync(async () => { - await membership.update({ role: newRole }); + return await membership.update({ role: newRole }); }) .catch(err => handleError(err, [], card.setError)); }; @@ -36,9 +29,9 @@ export const ActiveMembersList = () => { return card .runAsync(async () => { const destroyedMembership = await membership.destroy(); + await memberships?.revalidate?.(); return destroyedMembership; }) - .then(mutateSwrState) .catch(err => handleError(err, [], card.setError)); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx index 64339007f89..4da8c1a2c03 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx @@ -66,7 +66,7 @@ export const RemoveDomainPage = () => { successMessage={localizationKeys('organizationProfile.removeDomainPage.successMessage', { domain: ref.current?.name, })} - deleteResource={() => domain?.delete().then(() => (domains as any).unstable__mutate())} + deleteResource={() => domain?.delete().then(() => domains?.revalidate?.())} breadcrumbTitle={localizationKeys('organizationProfile.profilePage.domainSection.title')} Breadcrumbs={OrganizationProfileBreadcrumbs} /> diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/RequestToJoinList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/RequestToJoinList.tsx index 1ec22ef807a..e494b8f4a31 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/RequestToJoinList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/RequestToJoinList.tsx @@ -49,23 +49,29 @@ const RequestRow = withCardStateProvider( (props: { request: OrganizationMembershipRequestResource; onError: ReturnType['setError'] }) => { const { request, onError } = props; const card = useCardState(); - const { membershipRequests } = useCoreOrganization({ + const { membership, membershipRequests } = useCoreOrganization({ membershipRequests: membershipRequestsParams, }); const onAccept = () => { + if (!membership || !membershipRequests) { + return; + } return card .runAsync(async () => { await request.accept(); - await (membershipRequests as any).unstable__mutate?.(); + await membershipRequests.revalidate(); }, 'accept') .catch(err => handleError(err, [], onError)); }; const onReject = () => { + if (!membership || !membershipRequests) { + return; + } return card .runAsync(async () => { await request.reject(); - await (membershipRequests as any).unstable__mutate?.(); + await membershipRequests.revalidate(); }, 'reject') .catch(err => handleError(err, [], onError)); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx index b9be72731c5..02be6245755 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx @@ -55,7 +55,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => { const card = useCardState(); const { organizationSettings } = useEnvironment(); - const { organization, domains } = useCoreOrganization({ + const { membership, organization, domains } = useCoreOrganization({ domains: { infinite: true, }, @@ -147,7 +147,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => { }); const updateEnrollmentMode = async () => { - if (!domain || !organization) { + if (!domain || !organization || !membership || !domains) { return; } @@ -157,7 +157,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => { deletePending: deletePending.checked, }); - await (domains as any).unstable__mutate(); + await domains.revalidate(); await navigate('../../'); } catch (e) { diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx index fac892d69cf..847ccfea658 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx @@ -9,7 +9,7 @@ import { useInView } from '../../hooks'; import type { PropsOfComponent } from '../../styledSystem'; import { common } from '../../styledSystem'; import { handleError } from '../../utils'; -import { organizationListParams, removeItemFromPaginatedCache, updateCacheInPlace } from './utils'; +import { organizationListParams, populateCacheRemoveItem, populateCacheUpdateItem } from './utils'; const useFetchInvitations = () => { const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams); @@ -44,7 +44,7 @@ const AcceptRejectSuggestionButtons = (props: OrganizationSuggestionResource) => const handleAccept = () => { return card .runAsync(props.accept) - .then(updateCacheInPlace(userSuggestions)) + .then(updatedItem => userSuggestions?.setData?.(pages => populateCacheUpdateItem(updatedItem, pages))) .catch(err => handleError(err, [], card.setError)); }; @@ -81,7 +81,7 @@ const AcceptRejectInvitationButtons = (props: UserOrganizationInvitationResource const handleAccept = () => { return card .runAsync(props.accept) - .then(removeItemFromPaginatedCache(userInvitations)) + .then(updatedItem => userInvitations?.setData?.(pages => populateCacheRemoveItem(updatedItem, pages))) .catch(err => handleError(err, [], card.setError)); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/utils.ts b/packages/clerk-js/src/ui/components/OrganizationSwitcher/utils.ts index 7a9c81e3c8f..38c62349587 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/utils.ts +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/utils.ts @@ -17,9 +17,20 @@ export const organizationListParams = { export const populateCacheUpdateItem = ( updatedItem: T, - itemsInfinitePages: ClerkPaginatedResponse[], + itemsInfinitePages: (ClerkPaginatedResponse | undefined)[] | undefined, ) => { + if (typeof itemsInfinitePages === 'undefined') { + return [{ data: [updatedItem], total_count: 1 }]; + } + + /** + * We should "preserve" an undefined page if one is found. For example if swr triggers 2 requests, page 1 & page2, and the request for page2 resolves first, at that point in memory itemsInfinitePages would look like this [undefined, {....}] + * if SWR says that has fetched 2 pages but the first result of is undefined, we should not return back an array with 1 item as this will end up having cacheKeys that point nowhere. + */ return itemsInfinitePages.map(item => { + if (typeof item === 'undefined') { + return item; + } const newData = item.data.map(obj => { if (obj.id === updatedItem.id) { return { @@ -33,38 +44,27 @@ export const populateCacheUpdateItem = ( }); }; -export const updateCacheInPlace = - (userSuggestions: any) => - (result: T): any => { - userSuggestions?.unstable__mutate?.(result, { - populateCache: populateCacheUpdateItem, - // Since `accept` gives back the updated information, - // we don't need to revalidate here. - revalidate: false, - }); - }; - export const populateCacheRemoveItem = ( updatedItem: T, - itemsInfinitePages: ClerkPaginatedResponse[], + itemsInfinitePages: (ClerkPaginatedResponse | undefined)[] | undefined, ) => { - const prevTotalCount = itemsInfinitePages[itemsInfinitePages.length - 1].total_count; + const prevTotalCount = itemsInfinitePages?.[itemsInfinitePages.length - 1]?.total_count; - return itemsInfinitePages.map(item => { + if (!prevTotalCount) { + return undefined; + } + + /** + * We should "preserve" an undefined page if one is found. For example if swr triggers 2 requests, page 1 & page2, and the request for page2 resolves first, at that point in memory itemsInfinitePages would look like this [undefined, {....}] + * if SWR says that has fetched 2 pages but the first result of is undefined, we should not return back an array with 1 item as this will end up having cacheKeys that point nowhere. + */ + return itemsInfinitePages?.map(item => { + if (typeof item === 'undefined') { + return item; + } const newData = item.data.filter(obj => { return obj.id !== updatedItem.id; }); return { ...item, data: newData, total_count: prevTotalCount - 1 }; }); }; - -export const removeItemFromPaginatedCache = - (userInvitations: any) => - (result: T): any => { - userInvitations?.unstable__mutate?.(result, { - populateCache: populateCacheRemoveItem, - // Since `accept` gives back the updated information, - // we don't need to revalidate here. - revalidate: false, - }); - }; diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx index 1e0fe64340e..a104d240d6b 100644 --- a/packages/shared/src/react/hooks/useOrganization.tsx +++ b/packages/shared/src/react/hooks/useOrganization.tsx @@ -56,7 +56,9 @@ type UseOrganizationParams = { }); }; -type UseOrganizationReturn = +type UseOrganization = ( + params?: T, +) => | { isLoaded: false; organization: undefined; @@ -103,14 +105,24 @@ type UseOrganizationReturn = */ membershipList: OrganizationMembershipResource[] | null | undefined; membership: OrganizationMembershipResource | null | undefined; - domains: PaginatedResources | null; - membershipRequests: PaginatedResources | null; - memberships: PaginatedResources | null; - invitations: PaginatedResources | null; + domains: PaginatedResources< + OrganizationDomainResource, + T['membershipRequests'] extends { infinite: true } ? true : false + > | null; + membershipRequests: PaginatedResources< + OrganizationMembershipRequestResource, + T['membershipRequests'] extends { infinite: true } ? true : false + > | null; + memberships: PaginatedResources< + OrganizationMembershipResource, + T['memberships'] extends { infinite: true } ? true : false + > | null; + invitations: PaginatedResources< + OrganizationInvitationResource, + T['invitations'] extends { infinite: true } ? true : false + > | null; }; -type UseOrganization = (params?: UseOrganizationParams) => UseOrganizationReturn; - const undefinedPaginatedResource = { data: undefined, count: undefined, @@ -124,6 +136,8 @@ const undefinedPaginatedResource = { fetchPrevious: undefined, hasNextPage: false, hasPreviousPage: false, + revalidate: undefined, + setData: undefined, } as const; export const useOrganization: UseOrganization = params => { diff --git a/packages/shared/src/react/hooks/useOrganizationList.tsx b/packages/shared/src/react/hooks/useOrganizationList.tsx index beae119aa12..606feb4934d 100644 --- a/packages/shared/src/react/hooks/useOrganizationList.tsx +++ b/packages/shared/src/react/hooks/useOrganizationList.tsx @@ -38,8 +38,26 @@ type UseOrganizationListParams = { }; type OrganizationList = ReturnType; +const undefinedPaginatedResource = { + data: undefined, + count: undefined, + isLoading: false, + isFetching: false, + isError: false, + page: undefined, + pageCount: undefined, + fetchPage: undefined, + fetchNext: undefined, + fetchPrevious: undefined, + hasNextPage: false, + hasPreviousPage: false, + revalidate: undefined, + setData: undefined, +} as const; -type UseOrganizationListReturn = +type UseOrganizationList = ( + params?: T, +) => | { isLoaded: false; /** @@ -60,29 +78,20 @@ type UseOrganizationListReturn = organizationList: OrganizationList; createOrganization: (params: CreateOrganizationParams) => Promise; setActive: SetActive; - userMemberships: PaginatedResources; - userInvitations: PaginatedResources; - userSuggestions: PaginatedResources; + userMemberships: PaginatedResources< + OrganizationMembershipResource, + T['userMemberships'] extends { infinite: true } ? true : false + >; + userInvitations: PaginatedResources< + UserOrganizationInvitationResource, + T['userInvitations'] extends { infinite: true } ? true : false + >; + userSuggestions: PaginatedResources< + OrganizationSuggestionResource, + T['userSuggestions'] extends { infinite: true } ? true : false + >; }; -const undefinedPaginatedResource = { - data: undefined, - count: undefined, - isLoading: false, - isFetching: false, - isError: false, - page: undefined, - pageCount: undefined, - fetchPage: undefined, - fetchNext: undefined, - fetchPrevious: undefined, - hasNextPage: false, - hasPreviousPage: false, - unstable__mutate: undefined, -} as const; - -type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn; - export const useOrganizationList: UseOrganizationList = params => { const { userMemberships, userInvitations, userSuggestions } = params || {}; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.ts index be8e12f281e..6d51342fce2 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.ts @@ -3,8 +3,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useSWR, useSWRInfinite } from '../clerk-swr'; -import type { ValueOrSetter } from '../types'; -import type { PaginatedResources } from '../types'; +import type { CacheSetter, PaginatedResources, ValueOrSetter } from '../types'; function getDifferentKeys(obj1: Record, obj2: Record): Record { const keysSet = new Set(Object.keys(obj2)); @@ -55,10 +54,26 @@ export const useWithSafeValues = (params: T | type ArrayType = DataArray extends Array ? ElementType : never; type ExtractData = Type extends { data: infer Data } ? ArrayType : Type; +type DefaultOptions = { + /** + * Persists the previous pages with new ones in the same array + */ + infinite?: boolean; + /** + * Return the previous key's data until the new data has been loaded + */ + keepPreviousData?: boolean; + /** + * Should a request be triggered + */ + enabled?: boolean; +}; + type UsePagesOrInfinite = < Params extends PagesOrInfiniteOptions, FetcherReturnData extends Record, CacheKeys = Record, + TOptions extends DefaultOptions = DefaultOptions, >( /** * The parameters will be passed to the fetcher @@ -71,24 +86,9 @@ type UsePagesOrInfinite = < /** * Internal configuration of the hook */ - options: { - /** - * Persists the previous pages with new ones in the same array - */ - infinite?: boolean; - /** - * Return the previous key's data until the new data has been loaded - */ - keepPreviousData?: boolean; - /** - * Should a request be triggered - */ - enabled?: boolean; - }, + options: TOptions, cacheKeys: CacheKeys, -) => PaginatedResources> & { - unstable__mutate: () => Promise; -}; +) => PaginatedResources, TOptions['infinite']>; export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, options, cacheKeys) => { const [paginatedPage, setPaginatedPage] = useState(params.initialPage ?? 1); @@ -206,7 +206,17 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, options, const hasNextPage = count - offsetCount * pageSizeRef.current > page * pageSizeRef.current; const hasPreviousPage = (page - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; - const unstable__mutate = triggerInfinite ? swrInfiniteMutate : swrMutate; + const setData: CacheSetter = triggerInfinite + ? value => + swrInfiniteMutate(value, { + revalidate: false, + }) + : value => + swrMutate(value, { + revalidate: false, + }); + + const revalidate = triggerInfinite ? () => swrInfiniteMutate() : () => swrMutate(); return { data, @@ -221,6 +231,9 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, options, fetchPrevious, hasNextPage, hasPreviousPage, - unstable__mutate, + // Let the hook return type define this type + revalidate: revalidate as any, + // Let the hook return type define this type + setData: setData as any, }; }; diff --git a/packages/shared/src/react/types.ts b/packages/shared/src/react/types.ts index a2614cb214d..7a8abff1287 100644 --- a/packages/shared/src/react/types.ts +++ b/packages/shared/src/react/types.ts @@ -1,5 +1,12 @@ +import type { ClerkPaginatedResponse } from '@clerk/types'; + export type ValueOrSetter = (size: T | ((_size: T) => T)) => void; -export type PaginatedResources = { + +export type CacheSetter = ( + data?: CData | ((currentData?: CData) => Promise | undefined | CData), +) => Promise; + +export type PaginatedResources = { data: T[]; count: number; isLoading: boolean; @@ -12,11 +19,15 @@ export type PaginatedResources = { fetchNext: () => void; hasNextPage: boolean; hasPreviousPage: boolean; + revalidate: () => Promise; + setData: Infinite extends true + ? // Array of pages of data + CacheSetter<(ClerkPaginatedResponse | undefined)[]> + : // Array of data + CacheSetter | undefined>; }; // Utility type to convert PaginatedDataAPI to properties as undefined, except booleans set to false export type PaginatedResourcesWithDefault = { - [K in keyof PaginatedResources]: PaginatedResources[K] extends boolean - ? false - : PaginatedResources[K] | undefined; + [K in keyof PaginatedResources]: PaginatedResources[K] extends boolean ? false : undefined; };