From 1fd36a16ca372acc09c6cd255d774c55c7cc0269 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 26 Aug 2026 23:37:40 -0700 Subject: [PATCH 1/7] feat(self-serve-ds): add internal Directory Sync react-query hooks __internal_useOrganizationDirectorySync and __internal_useOrganizationDirectorySyncUsers; the users hook polls continuously while enabled so the test step can double as a recent-activity feed. --- packages/shared/src/react/hooks/index.ts | 10 ++ .../useOrganizationDirectorySync.shared.ts | 54 +++++++ .../hooks/useOrganizationDirectorySync.tsx | 145 +++++++++++++++++ .../useOrganizationDirectorySyncUsers.tsx | 147 ++++++++++++++++++ packages/shared/src/react/stable-keys.ts | 4 + 5 files changed, 360 insertions(+) create mode 100644 packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts create mode 100644 packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx create mode 100644 packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 3c4aa2ab8d5..5fe796fc0f4 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -49,6 +49,16 @@ export type { } from './useOrganizationEnterpriseConnections'; export { __internal_useOrganizationDomains } from './useOrganizationDomains'; export type { UseOrganizationDomainsParams, UseOrganizationDomainsReturn } from './useOrganizationDomains'; +export { __internal_useOrganizationDirectorySync } from './useOrganizationDirectorySync'; +export type { + UseOrganizationDirectorySyncParams, + UseOrganizationDirectorySyncReturn, +} from './useOrganizationDirectorySync'; +export { __internal_useOrganizationDirectorySyncUsers } from './useOrganizationDirectorySyncUsers'; +export type { + UseOrganizationDirectorySyncUsersParams, + UseOrganizationDirectorySyncUsersReturn, +} from './useOrganizationDirectorySyncUsers'; export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns'; export type { UseOrganizationEnterpriseConnectionTestRunsParams, diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts new file mode 100644 index 00000000000..08228379a1c --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; + +import type { GetDirectorySyncUsersParams } from '../../types/directorySync'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * @internal + */ +export function useOrganizationDirectorySyncCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; +}) { + const { organizationId, enterpriseConnectionId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + }, + untracked: { + args: {}, + }, + }); + }, [organizationId, enterpriseConnectionId]); +} + +/** + * @internal + */ +export function useOrganizationDirectorySyncUsersCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; + args: GetDirectorySyncUsersParams; +}) { + const { organizationId, enterpriseConnectionId, args } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + }, + untracked: { + args, + }, + }); + // The args object is intentionally serialized via the consumer to keep stability. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organizationId, enterpriseConnectionId, JSON.stringify(args)]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx new file mode 100644 index 00000000000..39381a43875 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -0,0 +1,145 @@ +import { useCallback } from 'react'; + +import { isClerkAPIResponseError } from '../../error'; +import type { DeletedObjectResource } from '../../types/deletedObject'; +import type { + CreateDirectorySyncParams, + DirectorySyncResource, + UpdateDirectorySyncParams, +} from '../../types/directorySync'; +import { useClerkInstanceContext } from '../contexts'; +import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncCacheKeys } from './useOrganizationDirectorySync.shared'; + +export type UseOrganizationDirectorySyncParams = { + enterpriseConnectionId: string | null; + enabled?: boolean; + keepPreviousData?: boolean; +}; + +export type UseOrganizationDirectorySyncReturn = { + /** + * The connection's directory, `null` when none has been created yet, `undefined` while loading. + * Never carries the bearer token — that only exists on the resources resolved by + * `createDirectorySync` and `rotateDirectorySyncToken`. + */ + data: DirectorySyncResource | null | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + createDirectorySync: (params?: CreateDirectorySyncParams) => Promise; + updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise; + rotateDirectorySyncToken: () => Promise; + deleteDirectorySync: () => Promise; + revalidate: () => Promise; +}; + +/** + * The Directory Sync directory bound to an enterprise connection of the active organization. + * + * @internal + */ +function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams): UseOrganizationDirectorySyncReturn { + const { enterpriseConnectionId, enabled = true, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, stableKey, authenticated } = useOrganizationDirectorySyncCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: async () => { + if (!enterpriseConnectionId) { + throw new Error('enterpriseConnectionId is required to fetch the directory'); + } + try { + return (await organization?.getDirectorySync(enterpriseConnectionId)) ?? null; + } catch (err) { + // No directory yet is a first-class state of the setup flow, not an error. + if (isClerkAPIResponseError(err) && err.status === 404) { + return null; + } + throw err; + } + }, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + }); + + const revalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: [stableKey] }), + [queryClient, stableKey], + ); + + const createDirectorySync = useCallback( + async (createParams?: CreateDirectorySyncParams) => { + if (!enterpriseConnectionId) { + return undefined; + } + const created = await organization?.createDirectorySync(enterpriseConnectionId, createParams); + await revalidate(); + return created; + }, + [organization, enterpriseConnectionId, revalidate], + ); + + const updateDirectorySync = useCallback( + async (updateParams: UpdateDirectorySyncParams) => { + if (!enterpriseConnectionId) { + return undefined; + } + const updated = await organization?.updateDirectorySync(enterpriseConnectionId, updateParams); + await revalidate(); + return updated; + }, + [organization, enterpriseConnectionId, revalidate], + ); + + const rotateDirectorySyncToken = useCallback(async () => { + if (!enterpriseConnectionId) { + return undefined; + } + const rotated = await organization?.rotateDirectorySyncToken(enterpriseConnectionId); + await revalidate(); + return rotated; + }, [organization, enterpriseConnectionId, revalidate]); + + const deleteDirectorySync = useCallback(async () => { + if (!enterpriseConnectionId) { + return undefined; + } + const deleted = await organization?.deleteDirectorySync(enterpriseConnectionId); + await revalidate(); + return deleted; + }, [organization, enterpriseConnectionId, revalidate]); + + return { + data: query.data, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + createDirectorySync, + updateDirectorySync, + rotateDirectorySyncToken, + deleteDirectorySync, + revalidate, + }; +} + +export { useOrganizationDirectorySync as __internal_useOrganizationDirectorySync }; diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx new file mode 100644 index 00000000000..2094c728b07 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { DirectorySyncUserResource, GetDirectorySyncUsersParams } from '../../types/directorySync'; +import { useClerkInstanceContext } from '../contexts'; +import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncUsersCacheKeys } from './useOrganizationDirectorySync.shared'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +export type UseOrganizationDirectorySyncUsersParams = { + enterpriseConnectionId: string | null; + /** + * Pass-through fetch parameters (pagination). + * Defaults to `{ initialPage: 1, pageSize: 10 }`. + */ + params?: GetDirectorySyncUsersParams; + /** + * Polling interval (ms) applied while polling is armed via `startPolling`. + * + * @default 2000 + */ + pollIntervalMs?: number; + /** + * If `false`, the hook is dormant — no fetch, no polling. + * + * @default true + */ + enabled?: boolean; + keepPreviousData?: boolean; +}; + +export type UseOrganizationDirectorySyncUsersReturn = { + data: DirectorySyncUserResource[] | undefined; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** + * `true` while the hook is actively polling + */ + isPolling: boolean; + /** + * Start polling. Polling runs continuously (new provisions, updates, and + * deprovisions keep appearing) until `stopPolling` is called — callers + * should stop on unmount of the view that armed it. + */ + startPolling: () => void; + /** + * Stop polling. + */ + stopPolling: () => void; + /** + * Force a refetch. + */ + revalidate: () => Promise; +}; + +/** + * The users provisioned into an enterprise connection's Directory Sync + * directory, most recently touched first. Polls continuously while armed via + * `startPolling`, so the setup flow doubles as a recent-activity feed. + * + * @internal + */ +function useOrganizationDirectorySyncUsers( + params: UseOrganizationDirectorySyncUsersParams, +): UseOrganizationDirectorySyncUsersReturn { + const { + enterpriseConnectionId, + params: fetchParams = { initialPage: 1, pageSize: 10 }, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + enabled = true, + keepPreviousData = true, + } = params; + + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + args: fetchParams, + }); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); + + const [shouldPoll, setShouldPoll] = useState(false); + + useEffect(() => { + // Polling intent is scoped to the current connection — clear it when the + // connection changes so a reset/recreate doesn't inherit a stale armed poll. + setShouldPoll(false); + }, [enterpriseConnectionId]); + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!enterpriseConnectionId) { + throw new Error('enterpriseConnectionId is required to fetch directory users'); + } + return organization?.getDirectorySyncUsers(enterpriseConnectionId, fetchParams); + }, + refetchInterval: () => (shouldPoll ? pollIntervalMs : false), + enabled: queryEnabled, + refetchIntervalInBackground: false, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + }); + + const startPolling = useCallback(() => { + setShouldPoll(true); + }, []); + + const stopPolling = useCallback(() => { + setShouldPoll(false); + }, []); + + const revalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey]); + + const isPolling = queryEnabled && shouldPoll; + + return { + data: query.data?.data, + totalCount: query.data?.total_count, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isPolling, + startPolling, + stopPolling, + revalidate, + }; +} + +export { useOrganizationDirectorySyncUsers as __internal_useOrganizationDirectorySyncUsers }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 6d7c6be925c..e7ae049abe6 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -83,6 +83,8 @@ const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns'; const ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY = 'organizationEnterpriseConnections'; const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterpriseConnectionTestRuns'; const ORGANIZATION_DOMAINS_KEY = 'organizationDomains'; +const ORGANIZATION_DIRECTORY_SYNC_KEY = 'organizationDirectorySync'; +const ORGANIZATION_DIRECTORY_SYNC_USERS_KEY = 'organizationDirectorySyncUsers'; const CREDIT_HISTORY_KEY = 'billing-credit-history'; @@ -96,6 +98,8 @@ export const INTERNAL_STABLE_KEYS = { ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY, ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY, ORGANIZATION_DOMAINS_KEY, + ORGANIZATION_DIRECTORY_SYNC_KEY, + ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, } as const; export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS]; From daa9293c96dfe1316d4439257d6e7424b82dc54c Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Fri, 28 Aug 2026 18:17:49 -0700 Subject: [PATCH 2/7] feat(self-serve-ds): route Directory Sync hooks through the DirectorySync resource Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U54pszNFtqsBNpQhXaGvaa --- .../hooks/useOrganizationDirectorySync.tsx | 21 +++++++++++-------- .../useOrganizationDirectorySyncUsers.tsx | 20 +++++++++++------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx index 39381a43875..1dea2eecea9 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -32,6 +32,7 @@ export type UseOrganizationDirectorySyncReturn = { isLoading: boolean; isFetching: boolean; createDirectorySync: (params?: CreateDirectorySyncParams) => Promise; + /** Resolves `undefined` until `data` has loaded, since the mutations act on the loaded directory. */ updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise; rotateDirectorySyncToken: () => Promise; deleteDirectorySync: () => Promise; @@ -99,35 +100,37 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams [organization, enterpriseConnectionId, revalidate], ); + const directory = query.data; + const updateDirectorySync = useCallback( async (updateParams: UpdateDirectorySyncParams) => { - if (!enterpriseConnectionId) { + if (!directory) { return undefined; } - const updated = await organization?.updateDirectorySync(enterpriseConnectionId, updateParams); + const updated = await directory.update(updateParams); await revalidate(); return updated; }, - [organization, enterpriseConnectionId, revalidate], + [directory, revalidate], ); const rotateDirectorySyncToken = useCallback(async () => { - if (!enterpriseConnectionId) { + if (!directory) { return undefined; } - const rotated = await organization?.rotateDirectorySyncToken(enterpriseConnectionId); + const rotated = await directory.rotateToken(); await revalidate(); return rotated; - }, [organization, enterpriseConnectionId, revalidate]); + }, [directory, revalidate]); const deleteDirectorySync = useCallback(async () => { - if (!enterpriseConnectionId) { + if (!directory) { return undefined; } - const deleted = await organization?.deleteDirectorySync(enterpriseConnectionId); + const deleted = await directory.delete(); await revalidate(); return deleted; - }, [organization, enterpriseConnectionId, revalidate]); + }, [directory, revalidate]); return { data: query.data, diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx index 2094c728b07..12e4bfdb298 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import type { DirectorySyncUserResource, GetDirectorySyncUsersParams } from '../../types/directorySync'; +import type { + DirectorySyncResource, + DirectorySyncUserResource, + GetDirectorySyncUsersParams, +} from '../../types/directorySync'; import { useClerkInstanceContext } from '../contexts'; import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; import { useClerkQueryClient } from '../query/use-clerk-query-client'; @@ -12,7 +16,8 @@ import { useOrganizationDirectorySyncUsersCacheKeys } from './useOrganizationDir const DEFAULT_POLL_INTERVAL_MS = 2_000; export type UseOrganizationDirectorySyncUsersParams = { - enterpriseConnectionId: string | null; + /** The directory to list users for, e.g. `data` from `useOrganizationDirectorySync`. Dormant while nullish. */ + directory: DirectorySyncResource | null | undefined; /** * Pass-through fetch parameters (pagination). * Defaults to `{ initialPage: 1, pageSize: 10 }`. @@ -70,7 +75,7 @@ function useOrganizationDirectorySyncUsers( params: UseOrganizationDirectorySyncUsersParams, ): UseOrganizationDirectorySyncUsersReturn { const { - enterpriseConnectionId, + directory, params: fetchParams = { initialPage: 1, pageSize: 10 }, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, enabled = true, @@ -80,6 +85,7 @@ function useOrganizationDirectorySyncUsers( const clerk = useClerkInstanceContext(); const organization = useOrganizationBase(); const [queryClient] = useClerkQueryClient(); + const enterpriseConnectionId = directory?.enterpriseConnectionId ?? null; const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({ organizationId: organization?.id ?? null, @@ -93,7 +99,7 @@ function useOrganizationDirectorySyncUsers( stableKeys: stableKey, }); - const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); const [shouldPoll, setShouldPoll] = useState(false); @@ -106,10 +112,10 @@ function useOrganizationDirectorySyncUsers( const query = useClerkQuery({ queryKey, queryFn: () => { - if (!enterpriseConnectionId) { - throw new Error('enterpriseConnectionId is required to fetch directory users'); + if (!directory) { + throw new Error('directory is required to fetch directory users'); } - return organization?.getDirectorySyncUsers(enterpriseConnectionId, fetchParams); + return directory.getUsers(fetchParams); }, refetchInterval: () => (shouldPoll ? pollIntervalMs : false), enabled: queryEnabled, From 41acc3c6701dad6102c84dcc9fb30aac7592d4c2 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 31 Aug 2026 15:36:07 -0700 Subject: [PATCH 3/7] Remove placeholder reuse --- .../useOrganizationDirectorySync.shared.ts | 6 ++-- .../hooks/useOrganizationDirectorySync.tsx | 6 ++-- .../useOrganizationDirectorySyncUsers.tsx | 30 ++++++++++++++----- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts index 08228379a1c..26d51bd0d32 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts @@ -33,9 +33,10 @@ export function useOrganizationDirectorySyncCacheKeys(params: { export function useOrganizationDirectorySyncUsersCacheKeys(params: { organizationId: string | null; enterpriseConnectionId: string | null; + directoryId: string | null; args: GetDirectorySyncUsersParams; }) { - const { organizationId, enterpriseConnectionId, args } = params; + const { organizationId, enterpriseConnectionId, directoryId, args } = params; return useMemo(() => { return createCacheKeys({ stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, @@ -43,6 +44,7 @@ export function useOrganizationDirectorySyncUsersCacheKeys(params: { tracked: { organizationId: organizationId ?? null, enterpriseConnectionId: enterpriseConnectionId ?? null, + directoryId: directoryId ?? null, }, untracked: { args, @@ -50,5 +52,5 @@ export function useOrganizationDirectorySyncUsersCacheKeys(params: { }); // The args object is intentionally serialized via the consumer to keep stability. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [organizationId, enterpriseConnectionId, JSON.stringify(args)]); + }, [organizationId, enterpriseConnectionId, directoryId, JSON.stringify(args)]); } diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx index 1dea2eecea9..6b4508e3008 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -8,7 +8,6 @@ import type { UpdateDirectorySyncParams, } from '../../types/directorySync'; import { useClerkInstanceContext } from '../contexts'; -import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; import { useClerkQueryClient } from '../query/use-clerk-query-client'; import { useClerkQuery } from '../query/useQuery'; import { useOrganizationBase } from './base/useOrganizationBase'; @@ -18,7 +17,6 @@ import { useOrganizationDirectorySyncCacheKeys } from './useOrganizationDirector export type UseOrganizationDirectorySyncParams = { enterpriseConnectionId: string | null; enabled?: boolean; - keepPreviousData?: boolean; }; export type UseOrganizationDirectorySyncReturn = { @@ -45,7 +43,7 @@ export type UseOrganizationDirectorySyncReturn = { * @internal */ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams): UseOrganizationDirectorySyncReturn { - const { enterpriseConnectionId, enabled = true, keepPreviousData = true } = params; + const { enterpriseConnectionId, enabled = true } = params; const clerk = useClerkInstanceContext(); const organization = useOrganizationBase(); const [queryClient] = useClerkQueryClient(); @@ -80,7 +78,7 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams } }, enabled: queryEnabled, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), + // No placeholderData: any key change is an identity change, and the mutations act on `query.data`. }); const revalidate = useCallback( diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx index 12e4bfdb298..958ad7f5747 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -6,7 +6,6 @@ import type { GetDirectorySyncUsersParams, } from '../../types/directorySync'; import { useClerkInstanceContext } from '../contexts'; -import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; import { useClerkQueryClient } from '../query/use-clerk-query-client'; import { useClerkQuery } from '../query/useQuery'; import { useOrganizationBase } from './base/useOrganizationBase'; @@ -39,6 +38,7 @@ export type UseOrganizationDirectorySyncUsersParams = { }; export type UseOrganizationDirectorySyncUsersReturn = { + /** `undefined` while loading and while the hook is dormant. */ data: DirectorySyncUserResource[] | undefined; totalCount: number | undefined; error: Error | null; @@ -90,6 +90,7 @@ function useOrganizationDirectorySyncUsers( const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({ organizationId: organization?.id ?? null, enterpriseConnectionId, + directoryId: directory?.id ?? null, args: fetchParams, }); @@ -104,11 +105,12 @@ function useOrganizationDirectorySyncUsers( const [shouldPoll, setShouldPoll] = useState(false); useEffect(() => { - // Polling intent is scoped to the current connection — clear it when the - // connection changes so a reset/recreate doesn't inherit a stale armed poll. + // Polling intent is scoped to the current directory — clear it when the + // identity changes so a reset/recreate doesn't inherit a stale armed poll. setShouldPoll(false); - }, [enterpriseConnectionId]); + }, [enterpriseConnectionId, directory?.id]); + const currentTracked = queryKey[2]; const query = useClerkQuery({ queryKey, queryFn: () => { @@ -120,7 +122,20 @@ function useOrganizationDirectorySyncUsers( refetchInterval: () => (shouldPoll ? pollIntervalMs : false), enabled: queryEnabled, refetchIntervalInBackground: false, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), + // Carry previous data only across pagination within the same organization + // and directory — never across an identity change, where stale rows would + // leak into the new context. + placeholderData: keepPreviousData + ? (previousData, previousQuery) => { + const previousTracked = previousQuery?.queryKey[2]; + const sameIdentity = + Boolean(currentTracked.organizationId) && + Boolean(currentTracked.directoryId) && + previousTracked?.organizationId === currentTracked.organizationId && + previousTracked?.directoryId === currentTracked.directoryId; + return sameIdentity ? previousData : undefined; + } + : undefined, }); const startPolling = useCallback(() => { @@ -138,8 +153,9 @@ function useOrganizationDirectorySyncUsers( const isPolling = queryEnabled && shouldPoll; return { - data: query.data?.data, - totalCount: query.data?.total_count, + // Dormant means dormant: never surface cached rows while the query cannot run. + data: queryEnabled ? query.data?.data : undefined, + totalCount: queryEnabled ? query.data?.total_count : undefined, error: query.error ?? null, isLoading: query.isLoading, isFetching: query.isFetching, From c38b6c0f9ead225c34d9c9b5034c6237181e8391 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 2 Sep 2026 13:56:50 -0700 Subject: [PATCH 4/7] Fix test result polling, and invalidation Also add tests --- .../useOrganizationDirectorySync.spec.tsx | 92 +++++++++++++ ...useOrganizationDirectorySyncUsers.spec.tsx | 121 ++++++++++++++++++ ...ationEnterpriseConnectionTestRuns.spec.tsx | 70 +++++++++- .../hooks/useOrganizationDirectorySync.tsx | 6 +- .../useOrganizationDirectorySyncUsers.tsx | 22 ++-- ...ganizationEnterpriseConnectionTestRuns.tsx | 17 +-- 6 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx create mode 100644 packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx new file mode 100644 index 00000000000..b124c15dfa6 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx @@ -0,0 +1,92 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkAPIResponseError } from '@/error'; + +import { INTERNAL_STABLE_KEYS } from '../../stable-keys'; +import { createCacheKeys } from '../createCacheKeys'; +import { __internal_useOrganizationDirectorySync } from '../useOrganizationDirectorySync'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +const directory = { id: 'dir_1', enterpriseConnectionId: 'ent_1' }; +const getDirectorySyncSpy = vi.fn((_enterpriseConnectionId: string) => Promise.resolve(directory)); + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, + __internal_lastEmittedResources: { + user: null, + session: null, + organization: { id: 'org_1', getDirectorySync: getDirectorySyncSpy }, + client: null, + }, +}); + +vi.mock('../../contexts', () => ({ + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useInitialStateContext: () => undefined, +})); + +const keysFor = (enterpriseConnectionId: string) => + createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY, + authenticated: true, + tracked: { organizationId: 'org_1', enterpriseConnectionId }, + untracked: { args: {} }, + }); + +const renderDirectorySync = (enterpriseConnectionId: string | null = 'ent_1') => + renderHook(() => __internal_useOrganizationDirectorySync({ enterpriseConnectionId }), { wrapper }); + +describe('useOrganizationDirectorySync', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + }); + + it('resolves the directory for the connection', async () => { + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getDirectorySyncSpy).toHaveBeenCalledWith('ent_1'); + expect(result.current.data).toBe(directory); + expect(result.current.error).toBeNull(); + }); + + it('treats a 404 as "no directory yet" and resolves null instead of an error', async () => { + getDirectorySyncSpy.mockRejectedValueOnce(new ClerkAPIResponseError('Not found', { status: 404, data: [] })); + + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('stays dormant without an enterprise connection id', () => { + const { result } = renderDirectorySync(null); + + expect(getDirectorySyncSpy).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + }); + + it('revalidate refetches only this org+connection, leaving other connections cached', async () => { + const { queryKey: otherKey } = keysFor('ent_other'); + defaultQueryClient.client.setQueryData(otherKey, { id: 'dir_other', enterpriseConnectionId: 'ent_other' }); + + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.revalidate(); + }); + + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(2); + expect(defaultQueryClient.client.getQueryState(otherKey)?.isInvalidated).toBe(false); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx new file mode 100644 index 00000000000..eb4e6cd034a --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx @@ -0,0 +1,121 @@ +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import React, { useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectorySyncResource } from '@/types/directorySync'; + +import type { UseOrganizationDirectorySyncUsersReturn } from '../useOrganizationDirectorySyncUsers'; +import { __internal_useOrganizationDirectorySyncUsers } from '../useOrganizationDirectorySyncUsers'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +const POLL_INTERVAL_MS = 20; + +const getUsersSpy = vi.fn(() => Promise.resolve({ data: [{ id: 'du_1' }], total_count: 1 })); + +const createDirectory = (id: string) => + ({ id, enterpriseConnectionId: 'ent_1', getUsers: getUsersSpy }) as unknown as DirectorySyncResource; + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, + __internal_lastEmittedResources: { + user: null, + session: null, + organization: { id: 'org_1' }, + client: null, + }, +}); + +vi.mock('../../contexts', () => ({ + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useInitialStateContext: () => undefined, +})); + +const renderUsers = (initialDirectory: DirectorySyncResource | null) => + renderHook( + ({ directory }: { directory: DirectorySyncResource | null }) => + __internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS }), + { wrapper, initialProps: { directory: initialDirectory } }, + ); + +describe('useOrganizationDirectorySyncUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + }); + + it('stays dormant without a directory', () => { + const { result } = renderUsers(null); + + expect(getUsersSpy).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPolling).toBe(false); + }); + + it('polls while armed and stops on stopPolling', async () => { + const { result } = renderUsers(createDirectory('dir_1')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isPolling).toBe(false); + + act(() => result.current.startPolling()); + expect(result.current.isPolling).toBe(true); + await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); + + act(() => result.current.stopPolling()); + expect(result.current.isPolling).toBe(false); + }); + + it('disarms polling when the directory identity changes', async () => { + const { result, rerender } = renderUsers(createDirectory('dir_1')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => result.current.startPolling()); + expect(result.current.isPolling).toBe(true); + + rerender({ directory: createDirectory('dir_2') }); + expect(result.current.isPolling).toBe(false); + }); + + it('keeps polling armed by a child effect in the same commit the directory arrives', async () => { + let latest: UseOrganizationDirectorySyncUsersReturn | undefined; + + // Child effects run before parent effects, so this is the ordering a + // reset-in-effect implementation would silently cancel. + const Child = ({ + directory, + startPolling, + }: { + directory: DirectorySyncResource | null; + startPolling: () => void; + }) => { + useEffect(() => { + if (directory) { + startPolling(); + } + }, [directory, startPolling]); + return null; + }; + + const Parent = ({ directory }: { directory: DirectorySyncResource | null }) => { + latest = __internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS }); + return ( + + ); + }; + + const { rerender } = render(); + expect(latest?.isPolling).toBe(false); + + rerender(); + + await waitFor(() => expect(latest?.isPolling).toBe(true)); + await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx index d943e33cd45..182f71e3b0d 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx @@ -1,10 +1,12 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import React, { useEffect } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GetEnterpriseConnectionTestRunsParams } from '@/types/enterpriseConnectionTestRun'; import { INTERNAL_STABLE_KEYS } from '../../stable-keys'; import { createCacheKeys } from '../createCacheKeys'; +import type { UseOrganizationEnterpriseConnectionTestRunsReturn } from '../useOrganizationEnterpriseConnectionTestRuns'; import { __internal_useOrganizationEnterpriseConnectionTestRuns } from '../useOrganizationEnterpriseConnectionTestRuns'; import { createMockClerk, createMockQueryClient } from './mocks/clerk'; import { wrapper } from './wrapper'; @@ -99,3 +101,69 @@ describe('useOrganizationEnterpriseConnectionTestRuns — revalidate invalidatio invalidateSpy.mockRestore(); }); }); + +describe('useOrganizationEnterpriseConnectionTestRuns — polling arm scope', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + }); + + it('keeps polling armed by a child effect in the same commit the connection arrives', async () => { + getTestRunsSpy.mockImplementation(() => Promise.resolve({ data: [], total_count: 0 })); + let latest: UseOrganizationEnterpriseConnectionTestRunsReturn | undefined; + + // Child effects run before parent effects, so this is the ordering a + // reset-in-effect implementation would silently cancel. + const Child = ({ + enterpriseConnectionId, + revalidate, + }: { + enterpriseConnectionId: string | null; + revalidate: UseOrganizationEnterpriseConnectionTestRunsReturn['revalidate']; + }) => { + useEffect(() => { + if (enterpriseConnectionId) { + void revalidate(); + } + }, [enterpriseConnectionId, revalidate]); + return null; + }; + + const Parent = ({ enterpriseConnectionId }: { enterpriseConnectionId: string | null }) => { + latest = __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId, pollIntervalMs: 20 }); + return ( + + ); + }; + + const { rerender } = render(); + expect(latest?.isPolling).toBe(false); + + rerender(); + + await waitFor(() => expect(latest?.isPolling).toBe(true)); + await waitFor(() => expect(getTestRunsSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); + }); + + it('disarms polling when the connection changes', async () => { + getTestRunsSpy.mockImplementation(() => Promise.resolve({ data: [], total_count: 0 })); + const { result, rerender } = renderHook( + ({ enterpriseConnectionId }: { enterpriseConnectionId: string }) => + __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId, pollIntervalMs: 20 }), + { wrapper, initialProps: { enterpriseConnectionId: 'ent_1' } }, + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.revalidate(); + }); + expect(result.current.isPolling).toBe(true); + + rerender({ enterpriseConnectionId: 'ent_2' }); + expect(result.current.isPolling).toBe(false); + }); +}); diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx index 6b4508e3008..bf2d534d0b4 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -48,7 +48,7 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams const organization = useOrganizationBase(); const [queryClient] = useClerkQueryClient(); - const { queryKey, stableKey, authenticated } = useOrganizationDirectorySyncCacheKeys({ + const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncCacheKeys({ organizationId: organization?.id ?? null, enterpriseConnectionId, }); @@ -82,8 +82,8 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams }); const revalidate = useCallback( - () => queryClient.invalidateQueries({ queryKey: [stableKey] }), - [queryClient, stableKey], + () => queryClient.invalidateQueries({ queryKey: invalidationKey }), + [queryClient, invalidationKey], ); const createDirectorySync = useCallback( diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx index 958ad7f5747..383287e2338 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import type { DirectorySyncResource, @@ -86,11 +86,12 @@ function useOrganizationDirectorySyncUsers( const organization = useOrganizationBase(); const [queryClient] = useClerkQueryClient(); const enterpriseConnectionId = directory?.enterpriseConnectionId ?? null; + const directoryId = directory?.id ?? null; const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({ organizationId: organization?.id ?? null, enterpriseConnectionId, - directoryId: directory?.id ?? null, + directoryId, args: fetchParams, }); @@ -102,13 +103,10 @@ function useOrganizationDirectorySyncUsers( const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); - const [shouldPoll, setShouldPoll] = useState(false); - - useEffect(() => { - // Polling intent is scoped to the current directory — clear it when the - // identity changes so a reset/recreate doesn't inherit a stale armed poll. - setShouldPoll(false); - }, [enterpriseConnectionId, directory?.id]); + // Polling is armed for a specific directory and derived, not reset in an effect: a child + // effect arming it in the same commit the directory arrives would otherwise be cancelled. + const [armedForDirectoryId, setArmedForDirectoryId] = useState(null); + const shouldPoll = armedForDirectoryId !== null && armedForDirectoryId === directoryId; const currentTracked = queryKey[2]; const query = useClerkQuery({ @@ -139,11 +137,11 @@ function useOrganizationDirectorySyncUsers( }); const startPolling = useCallback(() => { - setShouldPoll(true); - }, []); + setArmedForDirectoryId(directoryId); + }, [directoryId]); const stopPolling = useCallback(() => { - setShouldPoll(false); + setArmedForDirectoryId(null); }, []); const revalidate = useCallback(async () => { diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx index 88c65b31629..135e6d8bb7d 100644 --- a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx @@ -136,13 +136,10 @@ function useOrganizationEnterpriseConnectionTestRuns( const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); - const [shouldPoll, setShouldPoll] = useState(false); - - useEffect(() => { - // Polling intent is scoped to the current connection — clear it when the - // connection changes so a reset/recreate doesn't inherit a stale armed poll. - setShouldPoll(false); - }, [enterpriseConnectionId]); + // Polling is armed for a specific connection and derived, not reset in an effect: a child + // effect arming it in the same commit the connection arrives would otherwise be cancelled. + const [armedForConnectionId, setArmedForConnectionId] = useState(null); + const shouldPoll = armedForConnectionId !== null && armedForConnectionId === enterpriseConnectionId; const query = useClerkQuery({ queryKey, @@ -169,7 +166,7 @@ function useOrganizationEnterpriseConnectionTestRuns( useEffect(() => { if (shouldPoll && hasRows) { - setShouldPoll(false); + setArmedForConnectionId(null); } }, [shouldPoll, hasRows]); @@ -184,7 +181,7 @@ function useOrganizationEnterpriseConnectionTestRuns( // off. Once any record has been seen, this is a one-shot refetch. const armPolling = options?.armPolling ?? true; if (armPolling && !hasRows) { - setShouldPoll(true); + setArmedForConnectionId(enterpriseConnectionId); } // `invalidateQueries` awaits the refetch it triggers, so by the time it // resolves the cache already holds the fresh page. Read it back from the @@ -209,7 +206,7 @@ function useOrganizationEnterpriseConnectionTestRuns( }>(queryKey); return { data: fresh?.data, totalCount: fresh?.total_count }; }, - [queryClient, invalidationKey, queryKey, hasRows], + [queryClient, invalidationKey, queryKey, hasRows, enterpriseConnectionId], ); const isPolling = queryEnabled && shouldPoll && !hasRows; From 92ef053aea8543db43441c6acca7911b458347f5 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 8 Sep 2026 10:59:22 -0700 Subject: [PATCH 5/7] Address review feedback --- .../useOrganizationDirectorySync.spec.tsx | 108 +++++++++++++++++- ...useOrganizationDirectorySyncUsers.spec.tsx | 21 ++++ ...ationEnterpriseConnectionTestRuns.spec.tsx | 24 ++++ .../hooks/useOrganizationDirectorySync.tsx | 6 +- .../useOrganizationDirectorySyncUsers.tsx | 48 ++++---- ...ganizationEnterpriseConnectionTestRuns.tsx | 18 ++- 6 files changed, 189 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx index b124c15dfa6..c8b507d104b 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx @@ -9,8 +9,20 @@ import { __internal_useOrganizationDirectorySync } from '../useOrganizationDirec import { createMockClerk, createMockQueryClient } from './mocks/clerk'; import { wrapper } from './wrapper'; -const directory = { id: 'dir_1', enterpriseConnectionId: 'ent_1' }; +const updateSpy = vi.fn(() => Promise.resolve({ ...directory, name: 'Renamed' })); +const rotateTokenSpy = vi.fn(() => Promise.resolve({ ...directory, token: 'tok_new' })); +const deleteSpy = vi.fn(() => Promise.resolve({ object: 'directory', id: 'dir_1', deleted: true })); +const directory = { + id: 'dir_1', + enterpriseConnectionId: 'ent_1', + update: updateSpy, + rotateToken: rotateTokenSpy, + delete: deleteSpy, +}; const getDirectorySyncSpy = vi.fn((_enterpriseConnectionId: string) => Promise.resolve(directory)); +const createDirectorySyncSpy = vi.fn((_enterpriseConnectionId: string, _params?: unknown) => + Promise.resolve({ ...directory, token: 'tok_1' }), +); const defaultQueryClient = createMockQueryClient(); @@ -19,7 +31,7 @@ const mockClerk = createMockClerk({ __internal_lastEmittedResources: { user: null, session: null, - organization: { id: 'org_1', getDirectorySync: getDirectorySyncSpy }, + organization: { id: 'org_1', getDirectorySync: getDirectorySyncSpy, createDirectorySync: createDirectorySyncSpy }, client: null, }, }); @@ -89,4 +101,96 @@ describe('useOrganizationDirectorySync', () => { expect(getDirectorySyncSpy).toHaveBeenCalledTimes(2); expect(defaultQueryClient.client.getQueryState(otherKey)?.isInvalidated).toBe(false); }); + + describe('mutations', () => { + it('createDirectorySync creates for the connection, resolves the token-bearing resource, and refetches', async () => { + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1); + + let created: Awaited>; + await act(async () => { + created = await result.current.createDirectorySync({ name: 'Okta' }); + }); + + expect(createDirectorySyncSpy).toHaveBeenCalledWith('ent_1', { name: 'Okta' }); + expect(created).toMatchObject({ id: 'dir_1', token: 'tok_1' }); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(2); + }); + + it('createDirectorySync is a no-op without an enterprise connection id', async () => { + const { result } = renderDirectorySync(null); + + await expect(result.current.createDirectorySync()).resolves.toBeUndefined(); + expect(createDirectorySyncSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'updateDirectorySync', + updateSpy, + (r: ReturnType['result']) => r.current.updateDirectorySync({ name: 'Renamed' }), + ], + [ + 'rotateDirectorySyncToken', + rotateTokenSpy, + (r: ReturnType['result']) => r.current.rotateDirectorySyncToken(), + ], + [ + 'deleteDirectorySync', + deleteSpy, + (r: ReturnType['result']) => r.current.deleteDirectorySync(), + ], + ] as const)('%s acts on the loaded directory and refetches it', async (_name, resourceSpy, run) => { + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1); + + let resolved: unknown; + await act(async () => { + resolved = await run(result); + }); + + expect(resourceSpy).toHaveBeenCalledTimes(1); + expect(resolved).toBe(await resourceSpy.mock.results[0].value); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(2); + }); + + it('updateDirectorySync forwards its params to the resource', async () => { + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.updateDirectorySync({ name: 'Renamed' }); + }); + + expect(updateSpy).toHaveBeenCalledWith({ name: 'Renamed' }); + }); + + it('directory-scoped mutations resolve undefined before the directory has loaded', async () => { + getDirectorySyncSpy.mockRejectedValueOnce(new ClerkAPIResponseError('Not found', { status: 404, data: [] })); + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBeNull(); + + await expect(result.current.updateDirectorySync({ name: 'Renamed' })).resolves.toBeUndefined(); + await expect(result.current.rotateDirectorySyncToken()).resolves.toBeUndefined(); + await expect(result.current.deleteDirectorySync()).resolves.toBeUndefined(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(rotateTokenSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('propagates a failed mutation and skips the refetch', async () => { + const { result } = renderDirectorySync(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1); + + const failure = new Error('rotate failed'); + rotateTokenSpy.mockRejectedValueOnce(failure); + + await expect(result.current.rotateDirectorySyncToken()).rejects.toBe(failure); + expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx index eb4e6cd034a..a40e2827f68 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx @@ -67,6 +67,10 @@ describe('useOrganizationDirectorySyncUsers', () => { act(() => result.current.stopPolling()); expect(result.current.isPolling).toBe(false); + + const callsAfterStop = getUsersSpy.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS * 3)); + expect(getUsersSpy.mock.calls.length).toBe(callsAfterStop); }); it('disarms polling when the directory identity changes', async () => { @@ -80,6 +84,23 @@ describe('useOrganizationDirectorySyncUsers', () => { expect(result.current.isPolling).toBe(false); }); + it('does not resume polling when the original directory returns', async () => { + const { result, rerender } = renderUsers(createDirectory('dir_1')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => result.current.startPolling()); + expect(result.current.isPolling).toBe(true); + + rerender({ directory: createDirectory('dir_2') }); + rerender({ directory: createDirectory('dir_1') }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isPolling).toBe(false); + + const callsAfterReturn = getUsersSpy.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS * 3)); + expect(getUsersSpy.mock.calls.length).toBe(callsAfterReturn); + }); + it('keeps polling armed by a child effect in the same commit the directory arrives', async () => { let latest: UseOrganizationDirectorySyncUsersReturn | undefined; diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx index 182f71e3b0d..5435d0cf849 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx @@ -166,4 +166,28 @@ describe('useOrganizationEnterpriseConnectionTestRuns — polling arm scope', () rerender({ enterpriseConnectionId: 'ent_2' }); expect(result.current.isPolling).toBe(false); }); + + it('does not resume polling when the original connection returns without a new revalidate', async () => { + getTestRunsSpy.mockImplementation(() => Promise.resolve({ data: [], total_count: 0 })); + const { result, rerender } = renderHook( + ({ enterpriseConnectionId }: { enterpriseConnectionId: string }) => + __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId, pollIntervalMs: 20 }), + { wrapper, initialProps: { enterpriseConnectionId: 'ent_1' } }, + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.revalidate(); + }); + expect(result.current.isPolling).toBe(true); + + rerender({ enterpriseConnectionId: 'ent_2' }); + rerender({ enterpriseConnectionId: 'ent_1' }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isPolling).toBe(false); + + const callsAfterReturn = getTestRunsSpy.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 60)); + expect(getTestRunsSpy.mock.calls.length).toBe(callsAfterReturn); + }); }); diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx index bf2d534d0b4..99688aebfda 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -20,11 +20,7 @@ export type UseOrganizationDirectorySyncParams = { }; export type UseOrganizationDirectorySyncReturn = { - /** - * The connection's directory, `null` when none has been created yet, `undefined` while loading. - * Never carries the bearer token — that only exists on the resources resolved by - * `createDirectorySync` and `rotateDirectorySyncToken`. - */ + /** The connection's directory, `null` when none has been created yet, `undefined` while loading. */ data: DirectorySyncResource | null | undefined; error: Error | null; isLoading: boolean; diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx index 383287e2338..3b6d55ce4b0 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { DirectorySyncResource, @@ -15,7 +15,7 @@ import { useOrganizationDirectorySyncUsersCacheKeys } from './useOrganizationDir const DEFAULT_POLL_INTERVAL_MS = 2_000; export type UseOrganizationDirectorySyncUsersParams = { - /** The directory to list users for, e.g. `data` from `useOrganizationDirectorySync`. Dormant while nullish. */ + /** The directory to list users for, e.g. `data` from `useOrganizationDirectorySync`. Nothing is fetched while `null` or `undefined`. */ directory: DirectorySyncResource | null | undefined; /** * Pass-through fetch parameters (pagination). @@ -23,13 +23,13 @@ export type UseOrganizationDirectorySyncUsersParams = { */ params?: GetDirectorySyncUsersParams; /** - * Polling interval (ms) applied while polling is armed via `startPolling`. + * Polling interval (ms) used while polling is active. * * @default 2000 */ pollIntervalMs?: number; /** - * If `false`, the hook is dormant — no fetch, no polling. + * If `false`, nothing is fetched and polling is paused. * * @default true */ @@ -38,25 +38,21 @@ export type UseOrganizationDirectorySyncUsersParams = { }; export type UseOrganizationDirectorySyncUsersReturn = { - /** `undefined` while loading and while the hook is dormant. */ + /** `undefined` while loading and while the hook is disabled. */ data: DirectorySyncUserResource[] | undefined; totalCount: number | undefined; error: Error | null; isLoading: boolean; isFetching: boolean; - /** - * `true` while the hook is actively polling - */ + /** `true` while the hook is polling. */ isPolling: boolean; /** - * Start polling. Polling runs continuously (new provisions, updates, and - * deprovisions keep appearing) until `stopPolling` is called — callers - * should stop on unmount of the view that armed it. + * Start polling for changes to the list. Polling continues until `stopPolling` + * is called or the hook unmounts, so colocate the hook with the view that + * needs the feed and call this from an effect on mount. */ startPolling: () => void; - /** - * Stop polling. - */ + /** Stop polling. */ stopPolling: () => void; /** * Force a refetch. @@ -66,8 +62,8 @@ export type UseOrganizationDirectorySyncUsersReturn = { /** * The users provisioned into an enterprise connection's Directory Sync - * directory, most recently touched first. Polls continuously while armed via - * `startPolling`, so the setup flow doubles as a recent-activity feed. + * directory, most recently touched first. Polling is opt-in via `startPolling`, + * which lets the setup flow use the list as a live activity feed. * * @internal */ @@ -103,10 +99,16 @@ function useOrganizationDirectorySyncUsers( const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); - // Polling is armed for a specific directory and derived, not reset in an effect: a child - // effect arming it in the same commit the directory arrives would otherwise be cancelled. - const [armedForDirectoryId, setArmedForDirectoryId] = useState(null); - const shouldPoll = armedForDirectoryId !== null && armedForDirectoryId === directoryId; + // Polling is requested for a specific directory, so a directory change stops it. This is + // derived rather than reset in an effect because a child component may call `startPolling` + // in the same commit the directory arrives, and an unconditional reset would cancel that. + const [pollingDirectoryId, setPollingDirectoryId] = useState(null); + const shouldPoll = pollingDirectoryId !== null && pollingDirectoryId === directoryId; + + useEffect(() => { + // Drop a request left over from a previous directory so it cannot resume if that directory returns. + setPollingDirectoryId(current => (current !== null && current !== directoryId ? null : current)); + }, [directoryId]); const currentTracked = queryKey[2]; const query = useClerkQuery({ @@ -137,11 +139,11 @@ function useOrganizationDirectorySyncUsers( }); const startPolling = useCallback(() => { - setArmedForDirectoryId(directoryId); + setPollingDirectoryId(directoryId); }, [directoryId]); const stopPolling = useCallback(() => { - setArmedForDirectoryId(null); + setPollingDirectoryId(null); }, []); const revalidate = useCallback(async () => { @@ -151,7 +153,7 @@ function useOrganizationDirectorySyncUsers( const isPolling = queryEnabled && shouldPoll; return { - // Dormant means dormant: never surface cached rows while the query cannot run. + // A disabled query still exposes rows cached under its key; report none until it can run. data: queryEnabled ? query.data?.data : undefined, totalCount: queryEnabled ? query.data?.total_count : undefined, error: query.error ?? null, diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx index 135e6d8bb7d..3ae3a71d95d 100644 --- a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx @@ -136,10 +136,16 @@ function useOrganizationEnterpriseConnectionTestRuns( const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); - // Polling is armed for a specific connection and derived, not reset in an effect: a child - // effect arming it in the same commit the connection arrives would otherwise be cancelled. - const [armedForConnectionId, setArmedForConnectionId] = useState(null); - const shouldPoll = armedForConnectionId !== null && armedForConnectionId === enterpriseConnectionId; + // Polling is requested for a specific connection, so a connection change stops it. This is + // derived rather than reset in an effect because a child component may call `revalidate` in + // the same commit the connection arrives, and an unconditional reset would cancel that. + const [pollingConnectionId, setPollingConnectionId] = useState(null); + const shouldPoll = pollingConnectionId !== null && pollingConnectionId === enterpriseConnectionId; + + useEffect(() => { + // Drop a request left over from a previous connection so it cannot resume if that connection returns. + setPollingConnectionId(current => (current !== null && current !== enterpriseConnectionId ? null : current)); + }, [enterpriseConnectionId]); const query = useClerkQuery({ queryKey, @@ -166,7 +172,7 @@ function useOrganizationEnterpriseConnectionTestRuns( useEffect(() => { if (shouldPoll && hasRows) { - setArmedForConnectionId(null); + setPollingConnectionId(null); } }, [shouldPoll, hasRows]); @@ -181,7 +187,7 @@ function useOrganizationEnterpriseConnectionTestRuns( // off. Once any record has been seen, this is a one-shot refetch. const armPolling = options?.armPolling ?? true; if (armPolling && !hasRows) { - setArmedForConnectionId(enterpriseConnectionId); + setPollingConnectionId(enterpriseConnectionId); } // `invalidateQueries` awaits the refetch it triggers, so by the time it // resolves the cache already holds the fresh page. Read it back from the From 5f6a5b21342076102b935df4a7193383e641b010 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 9 Sep 2026 14:09:39 -0700 Subject: [PATCH 6/7] Replace the startPolling/stopPolling pair with a `poll` flag. This will now poll for as long as the test step is mounted. --- ...useOrganizationDirectorySyncUsers.spec.tsx | 98 +++++-------------- .../useOrganizationDirectorySyncUsers.tsx | 49 +++------- 2 files changed, 39 insertions(+), 108 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx index a40e2827f68..0e0bdc5e692 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncUsers.spec.tsx @@ -1,10 +1,8 @@ -import { act, render, renderHook, waitFor } from '@testing-library/react'; -import React, { useEffect } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { DirectorySyncResource } from '@/types/directorySync'; -import type { UseOrganizationDirectorySyncUsersReturn } from '../useOrganizationDirectorySyncUsers'; import { __internal_useOrganizationDirectorySyncUsers } from '../useOrganizationDirectorySyncUsers'; import { createMockClerk, createMockQueryClient } from './mocks/clerk'; import { wrapper } from './wrapper'; @@ -34,11 +32,13 @@ vi.mock('../../contexts', () => ({ useInitialStateContext: () => undefined, })); -const renderUsers = (initialDirectory: DirectorySyncResource | null) => +type RenderProps = { directory: DirectorySyncResource | null; poll?: boolean }; + +const renderUsers = (initialProps: RenderProps) => renderHook( - ({ directory }: { directory: DirectorySyncResource | null }) => - __internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS }), - { wrapper, initialProps: { directory: initialDirectory } }, + ({ directory, poll }: RenderProps) => + __internal_useOrganizationDirectorySyncUsers({ directory, poll, pollIntervalMs: POLL_INTERVAL_MS }), + { wrapper, initialProps }, ); describe('useOrganizationDirectorySyncUsers', () => { @@ -49,23 +49,30 @@ describe('useOrganizationDirectorySyncUsers', () => { }); it('stays dormant without a directory', () => { - const { result } = renderUsers(null); + const { result } = renderUsers({ directory: null, poll: true }); expect(getUsersSpy).not.toHaveBeenCalled(); expect(result.current.data).toBeUndefined(); expect(result.current.isPolling).toBe(false); }); - it('polls while armed and stops on stopPolling', async () => { - const { result } = renderUsers(createDirectory('dir_1')); + it('does not poll by default', async () => { + const { result } = renderUsers({ directory: createDirectory('dir_1') }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.isPolling).toBe(false); - act(() => result.current.startPolling()); + const callsAfterLoad = getUsersSpy.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS * 3)); + expect(getUsersSpy.mock.calls.length).toBe(callsAfterLoad); + }); + + it('polls while `poll` is true and stops when it turns false', async () => { + const directory = createDirectory('dir_1'); + const { result, rerender } = renderUsers({ directory, poll: true }); expect(result.current.isPolling).toBe(true); await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); - act(() => result.current.stopPolling()); + rerender({ directory, poll: false }); expect(result.current.isPolling).toBe(false); const callsAfterStop = getUsersSpy.mock.calls.length; @@ -73,70 +80,15 @@ describe('useOrganizationDirectorySyncUsers', () => { expect(getUsersSpy.mock.calls.length).toBe(callsAfterStop); }); - it('disarms polling when the directory identity changes', async () => { - const { result, rerender } = renderUsers(createDirectory('dir_1')); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - act(() => result.current.startPolling()); - expect(result.current.isPolling).toBe(true); - - rerender({ directory: createDirectory('dir_2') }); - expect(result.current.isPolling).toBe(false); - }); - - it('does not resume polling when the original directory returns', async () => { - const { result, rerender } = renderUsers(createDirectory('dir_1')); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - act(() => result.current.startPolling()); + it('stops polling on unmount', async () => { + const { result, unmount } = renderUsers({ directory: createDirectory('dir_1'), poll: true }); + await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); expect(result.current.isPolling).toBe(true); - rerender({ directory: createDirectory('dir_2') }); - rerender({ directory: createDirectory('dir_1') }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.isPolling).toBe(false); + unmount(); - const callsAfterReturn = getUsersSpy.mock.calls.length; + const callsAfterUnmount = getUsersSpy.mock.calls.length; await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS * 3)); - expect(getUsersSpy.mock.calls.length).toBe(callsAfterReturn); - }); - - it('keeps polling armed by a child effect in the same commit the directory arrives', async () => { - let latest: UseOrganizationDirectorySyncUsersReturn | undefined; - - // Child effects run before parent effects, so this is the ordering a - // reset-in-effect implementation would silently cancel. - const Child = ({ - directory, - startPolling, - }: { - directory: DirectorySyncResource | null; - startPolling: () => void; - }) => { - useEffect(() => { - if (directory) { - startPolling(); - } - }, [directory, startPolling]); - return null; - }; - - const Parent = ({ directory }: { directory: DirectorySyncResource | null }) => { - latest = __internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS }); - return ( - - ); - }; - - const { rerender } = render(); - expect(latest?.isPolling).toBe(false); - - rerender(); - - await waitFor(() => expect(latest?.isPolling).toBe(true)); - await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3)); + expect(getUsersSpy.mock.calls.length).toBe(callsAfterUnmount); }); }); diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx index 3b6d55ce4b0..61c8bf70e73 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import type { DirectorySyncResource, @@ -23,7 +23,14 @@ export type UseOrganizationDirectorySyncUsersParams = { */ params?: GetDirectorySyncUsersParams; /** - * Polling interval (ms) used while polling is active. + * Poll the list for changes while `true`. Tie this to the view that needs the + * live feed so polling stops when that view goes away. + * + * @default false + */ + poll?: boolean; + /** + * Polling interval (ms) used while `poll` is `true`. * * @default 2000 */ @@ -46,14 +53,6 @@ export type UseOrganizationDirectorySyncUsersReturn = { isFetching: boolean; /** `true` while the hook is polling. */ isPolling: boolean; - /** - * Start polling for changes to the list. Polling continues until `stopPolling` - * is called or the hook unmounts, so colocate the hook with the view that - * needs the feed and call this from an effect on mount. - */ - startPolling: () => void; - /** Stop polling. */ - stopPolling: () => void; /** * Force a refetch. */ @@ -62,8 +61,8 @@ export type UseOrganizationDirectorySyncUsersReturn = { /** * The users provisioned into an enterprise connection's Directory Sync - * directory, most recently touched first. Polling is opt-in via `startPolling`, - * which lets the setup flow use the list as a live activity feed. + * directory, most recently touched first. Polling is opt-in via `poll`, which + * lets the setup flow use the list as a live activity feed. * * @internal */ @@ -73,6 +72,7 @@ function useOrganizationDirectorySyncUsers( const { directory, params: fetchParams = { initialPage: 1, pageSize: 10 }, + poll = false, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, enabled = true, keepPreviousData = true, @@ -99,17 +99,6 @@ function useOrganizationDirectorySyncUsers( const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); - // Polling is requested for a specific directory, so a directory change stops it. This is - // derived rather than reset in an effect because a child component may call `startPolling` - // in the same commit the directory arrives, and an unconditional reset would cancel that. - const [pollingDirectoryId, setPollingDirectoryId] = useState(null); - const shouldPoll = pollingDirectoryId !== null && pollingDirectoryId === directoryId; - - useEffect(() => { - // Drop a request left over from a previous directory so it cannot resume if that directory returns. - setPollingDirectoryId(current => (current !== null && current !== directoryId ? null : current)); - }, [directoryId]); - const currentTracked = queryKey[2]; const query = useClerkQuery({ queryKey, @@ -119,7 +108,7 @@ function useOrganizationDirectorySyncUsers( } return directory.getUsers(fetchParams); }, - refetchInterval: () => (shouldPoll ? pollIntervalMs : false), + refetchInterval: () => (poll ? pollIntervalMs : false), enabled: queryEnabled, refetchIntervalInBackground: false, // Carry previous data only across pagination within the same organization @@ -138,19 +127,11 @@ function useOrganizationDirectorySyncUsers( : undefined, }); - const startPolling = useCallback(() => { - setPollingDirectoryId(directoryId); - }, [directoryId]); - - const stopPolling = useCallback(() => { - setPollingDirectoryId(null); - }, []); - const revalidate = useCallback(async () => { await queryClient.invalidateQueries({ queryKey: invalidationKey }); }, [queryClient, invalidationKey]); - const isPolling = queryEnabled && shouldPoll; + const isPolling = queryEnabled && poll; return { // A disabled query still exposes rows cached under its key; report none until it can run. @@ -160,8 +141,6 @@ function useOrganizationDirectorySyncUsers( isLoading: query.isLoading, isFetching: query.isFetching, isPolling, - startPolling, - stopPolling, revalidate, }; } From 9f98b817309d9f0ff952da96c1739024aac48d76 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Thu, 10 Sep 2026 14:58:08 -0700 Subject: [PATCH 7/7] test(shared): use a valid update param in the directory sync hook spec Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QPL3FUbvSACz8y1oYCDept (cherry picked from commit c72aa9df5f640835981fa74871ffc6cb760900d2) --- .../__tests__/useOrganizationDirectorySync.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx index c8b507d104b..d3913185bbc 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySync.spec.tsx @@ -9,7 +9,7 @@ import { __internal_useOrganizationDirectorySync } from '../useOrganizationDirec import { createMockClerk, createMockQueryClient } from './mocks/clerk'; import { wrapper } from './wrapper'; -const updateSpy = vi.fn(() => Promise.resolve({ ...directory, name: 'Renamed' })); +const updateSpy = vi.fn(() => Promise.resolve({ ...directory, enabled: false })); const rotateTokenSpy = vi.fn(() => Promise.resolve({ ...directory, token: 'tok_new' })); const deleteSpy = vi.fn(() => Promise.resolve({ object: 'directory', id: 'dir_1', deleted: true })); const directory = { @@ -129,7 +129,7 @@ describe('useOrganizationDirectorySync', () => { [ 'updateDirectorySync', updateSpy, - (r: ReturnType['result']) => r.current.updateDirectorySync({ name: 'Renamed' }), + (r: ReturnType['result']) => r.current.updateDirectorySync({ enabled: false }), ], [ 'rotateDirectorySyncToken', @@ -161,10 +161,10 @@ describe('useOrganizationDirectorySync', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - await result.current.updateDirectorySync({ name: 'Renamed' }); + await result.current.updateDirectorySync({ enabled: false }); }); - expect(updateSpy).toHaveBeenCalledWith({ name: 'Renamed' }); + expect(updateSpy).toHaveBeenCalledWith({ enabled: false }); }); it('directory-scoped mutations resolve undefined before the directory has loaded', async () => { @@ -173,7 +173,7 @@ describe('useOrganizationDirectorySync', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data).toBeNull(); - await expect(result.current.updateDirectorySync({ name: 'Renamed' })).resolves.toBeUndefined(); + await expect(result.current.updateDirectorySync({ enabled: false })).resolves.toBeUndefined(); await expect(result.current.rotateDirectorySyncToken()).resolves.toBeUndefined(); await expect(result.current.deleteDirectorySync()).resolves.toBeUndefined(); expect(updateSpy).not.toHaveBeenCalled();