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/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..26d51bd0d32
--- /dev/null
+++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts
@@ -0,0 +1,56 @@
+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;
+ directoryId: string | null;
+ args: GetDirectorySyncUsersParams;
+}) {
+ const { organizationId, enterpriseConnectionId, directoryId, 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,
+ directoryId: directoryId ?? 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, directoryId, 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..bf2d534d0b4
--- /dev/null
+++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx
@@ -0,0 +1,146 @@
+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 { 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;
+};
+
+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;
+ /** Resolves `undefined` until `data` has loaded, since the mutations act on the loaded directory. */
+ 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 } = params;
+ const clerk = useClerkInstanceContext();
+ const organization = useOrganizationBase();
+ const [queryClient] = useClerkQueryClient();
+
+ const { queryKey, invalidationKey, 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,
+ // No placeholderData: any key change is an identity change, and the mutations act on `query.data`.
+ });
+
+ const revalidate = useCallback(
+ () => queryClient.invalidateQueries({ queryKey: invalidationKey }),
+ [queryClient, invalidationKey],
+ );
+
+ 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 directory = query.data;
+
+ const updateDirectorySync = useCallback(
+ async (updateParams: UpdateDirectorySyncParams) => {
+ if (!directory) {
+ return undefined;
+ }
+ const updated = await directory.update(updateParams);
+ await revalidate();
+ return updated;
+ },
+ [directory, revalidate],
+ );
+
+ const rotateDirectorySyncToken = useCallback(async () => {
+ if (!directory) {
+ return undefined;
+ }
+ const rotated = await directory.rotateToken();
+ await revalidate();
+ return rotated;
+ }, [directory, revalidate]);
+
+ const deleteDirectorySync = useCallback(async () => {
+ if (!directory) {
+ return undefined;
+ }
+ const deleted = await directory.delete();
+ await revalidate();
+ return deleted;
+ }, [directory, 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..383287e2338
--- /dev/null
+++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx
@@ -0,0 +1,167 @@
+import { useCallback, useState } from 'react';
+
+import type {
+ DirectorySyncResource,
+ DirectorySyncUserResource,
+ GetDirectorySyncUsersParams,
+} from '../../types/directorySync';
+import { useClerkInstanceContext } from '../contexts';
+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 = {
+ /** 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 }`.
+ */
+ 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 = {
+ /** `undefined` while loading and while the hook is dormant. */
+ 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 {
+ directory,
+ 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 enterpriseConnectionId = directory?.enterpriseConnectionId ?? null;
+ const directoryId = directory?.id ?? null;
+
+ const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({
+ organizationId: organization?.id ?? null,
+ enterpriseConnectionId,
+ directoryId,
+ args: fetchParams,
+ });
+
+ useClearQueriesOnSignOut({
+ isSignedOut: organization === null,
+ authenticated,
+ stableKeys: stableKey,
+ });
+
+ 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;
+
+ const currentTracked = queryKey[2];
+ const query = useClerkQuery({
+ queryKey,
+ queryFn: () => {
+ if (!directory) {
+ throw new Error('directory is required to fetch directory users');
+ }
+ return directory.getUsers(fetchParams);
+ },
+ refetchInterval: () => (shouldPoll ? pollIntervalMs : false),
+ enabled: queryEnabled,
+ refetchIntervalInBackground: false,
+ // 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(() => {
+ setArmedForDirectoryId(directoryId);
+ }, [directoryId]);
+
+ const stopPolling = useCallback(() => {
+ setArmedForDirectoryId(null);
+ }, []);
+
+ const revalidate = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey: invalidationKey });
+ }, [queryClient, invalidationKey]);
+
+ const isPolling = queryEnabled && shouldPoll;
+
+ return {
+ // 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,
+ isPolling,
+ startPolling,
+ stopPolling,
+ revalidate,
+ };
+}
+
+export { useOrganizationDirectorySyncUsers as __internal_useOrganizationDirectorySyncUsers };
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;
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];