diff --git a/.changeset/mosaic-user-sessions-query.md b/.changeset/mosaic-user-sessions-query.md new file mode 100644 index 00000000000..2230d7b6ad8 --- /dev/null +++ b/.changeset/mosaic-user-sessions-query.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add internal query helpers used by `@clerk/mosaic`. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index f371588452c..2e479cc3c28 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -50,6 +50,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | | An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` | | A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | +| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`use-user-sessions-query.ts`) | | An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | | An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) | diff --git a/packages/mosaic/docs/models.md b/packages/mosaic/docs/models.md index 103735f41cc..545dfb49553 100644 --- a/packages/mosaic/docs/models.md +++ b/packages/mosaic/docs/models.md @@ -121,7 +121,8 @@ drops the fallback instead of holding the space open. Keep the two apart. - No local UI state. What is open and what is in flight belong to the controller. - No React state machinery beyond the Clerk hooks themselves. Certain Mosaic hooks can also count when they deal with "outside" state, like `useReverificationFlow` - described above; the model is a derivation of what Clerk currently says. + described above, and the query hooks in `src/queries/` that cache a Clerk + resource read; the model is a derivation of what Clerk currently says. The other exception is what to fetch: a search query, page, or sort the Clerk hook takes as input lives in the model beside the page the hook already holds, and the model exposes a callback to change it (`search(query)` sets the diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index 4ea77788f19..f118b778084 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -34,11 +34,6 @@ "count": 1 } }, - "src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts": { - "@typescript-eslint/no-restricted-imports": { - "count": 1 - } - }, "src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts": { "@typescript-eslint/no-restricted-imports": { "count": 1 @@ -132,4 +127,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index 346e3cd2623..fa55f068a51 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -44,6 +44,11 @@ export default [ message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', allowTypeImports: true, }, + { + regex: '(^|/)queries/', + message: 'Read Clerk queries in the model and pass plain data to the controller or view.', + allowTypeImports: true, + }, { regex: '(^|/)use-mosaic-environment(?:[.]ts)?$', importNames: ['useMosaicEnvironment'], diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 156bab14ef5..72c3bec88e0 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -93,6 +93,17 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { expect(messages[0]).toContain('model'); }); + test.each([ + "import { useUserSessionsQuery } from '../queries/use-user-sessions-query';", + "import { useUserSessionsQuery } from '@/queries/use-user-sessions-query.ts';", + ])('rejects Clerk queries in views and controllers: %s', async source => { + for (const file of ['sample.view.tsx', 'sample.controller.ts']) { + const messages = await restrictedImports(file, source); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('model'); + } + }); + test.each(['sample.model.ts', 'sample.model.tsx'])( '%s sends React interaction state to the controller', async file => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx index 5bef9633396..8478eac8c6b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx @@ -1,3 +1,4 @@ +import { getClerkQueryClient } from '@clerk/shared/react'; import type { ActClaim, SessionWithActivitiesJSON } from '@clerk/shared/types'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, screen, waitFor, within } from '@testing-library/react'; @@ -131,7 +132,7 @@ describe('Active devices', () => { expect(await screen.findByText('This device')).toBeVisible(); }); - it('shows its skeleton again when remounted', async () => { + it('shows cached devices instead of its skeleton when remounted', async () => { serveDevices([device('sess_current', 'active')]); const view = await renderWithClerk(); expect(await screen.findByText('This device')).toBeVisible(); @@ -146,12 +147,11 @@ describe('Active devices', () => { ); try { view.rerender(); - expect(screen.getByRole('status')).toHaveTextContent('Loading active devices'); - expect(screen.queryByText('This device')).toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('Active devices loaded'); + expect(screen.getByText('This device')).toBeVisible(); } finally { pending.resolve(); } - expect(await screen.findByText('This device')).toBeVisible(); }); it('shows its skeleton instead of the old devices while a new session loads', async () => { @@ -188,6 +188,52 @@ describe('Active devices', () => { expect(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' })).toBeVisible(); }); + it('refreshes devices on mount only once the cached list is two minutes old', async () => { + serveDevices([device('sess_current', 'active')]); + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const view = await renderWithClerk(
); + const currentUser = view.clerk.user; + if (!currentUser) { + throw new Error('Expected a signed-in user'); + } + const reads = vi.spyOn(currentUser, 'getSessions'); + view.rerender(); + expect(await screen.findByText('This device')).toBeVisible(); + expect(reads).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 119_000); + view.rerender(
); + view.rerender(); + expect(reads).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 2_000); + view.rerender(
); + view.rerender(); + await waitFor(() => expect(reads).toHaveBeenCalledTimes(2)); + expect(screen.getByText('This device')).toBeVisible(); + } finally { + vi.useRealTimers(); + } + }); + + it('drops cached devices on sign out', async () => { + serveDevices([device('sess_current', 'active')]); + const view = await renderWithClerk(); + expect(await screen.findByText('This device')).toBeVisible(); + const cachedDevices = () => + getClerkQueryClient() + ?.getQueryCache() + .getAll() + .filter(query => query.state.data !== undefined) ?? []; + expect(cachedDevices()).toHaveLength(1); + + await act(() => view.clerk.signOut()); + + expect(view.container).toBeEmptyDOMElement(); + expect(cachedDevices()).toHaveLength(0); + }); + it('shows signed-in sessions and signs out another device inside the security panel', async () => { const devices = serveDevices([ device('sess_other', 'active', { device_type: 'iPhone', is_mobile: true }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 1527aaec41e..92fb3eaefed 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -2,10 +2,10 @@ import { differenceInCalendarDays } from '@clerk/shared/date'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; -import { useEffect, useState } from 'react'; import { useNow } from '../../../hooks/use-now'; import { fill, useLocale, useMessages } from '../../../localization'; +import { useUserSessionsQuery } from '../../../queries/use-user-sessions-query'; import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; import type { UserProfileDevice } from './user-profile-active-devices.types'; @@ -24,10 +24,6 @@ export type UserProfileActiveDevicesModel = export type ReadyActiveDevicesModel = Extract; -type SessionsQuery = - | { status: 'loading'; identity: string | undefined } - | { status: 'ready'; identity: string; sessions: SessionWithActivitiesResource[] }; - function isActiveDevice(session: SessionWithActivitiesResource): boolean { return session.status === 'active' || session.status === 'pending'; } @@ -53,25 +49,8 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode target.revoke(), ); + const { data: sessions } = useUserSessionsQuery(); const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined; - const [query, setQuery] = useState({ status: 'loading', identity }); - - useEffect(() => { - const currentUser = clerk.user; - if (!identity || !currentUser || currentUser.id !== userId || clerk.session?.id !== sessionId) { - return; - } - let active = true; - setQuery({ status: 'loading', identity }); - void currentUser.getSessions().then(sessions => { - if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) { - setQuery({ status: 'ready', identity, sessions }); - } - }); - return () => { - active = false; - }; - }, [clerk, userId, sessionId, identity]); const toDevice = (item: SessionWithActivitiesResource): UserProfileDevice => { const activity = item.latestActivity; @@ -106,19 +85,19 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode return { status: 'hidden' }; } - if (query.identity !== identity || query.status === 'loading') { + if (!sessions) { return { status: 'loadingDevices' }; } return { status: 'ready', identity, - devices: query.sessions + devices: sessions .filter(isActiveDevice) .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId)) .map(toDevice), revoke: async id => { const currentUser = clerk.user; - const target = query.sessions.find(item => item.id === id && isActiveDevice(item)); + const target = sessions.find(item => item.id === id && isActiveDevice(item)); if ( !target || !currentUser || diff --git a/packages/mosaic/src/queries/use-user-sessions-query.ts b/packages/mosaic/src/queries/use-user-sessions-query.ts new file mode 100644 index 00000000000..d8ce6e20c46 --- /dev/null +++ b/packages/mosaic/src/queries/use-user-sessions-query.ts @@ -0,0 +1,43 @@ +import { + __internal_createCacheKeys, + __internal_useClearQueriesOnSignOut, + __internal_useClerkQuery, + useSession, + useUser, +} from '@clerk/shared/react'; +import { useMemo } from 'react'; + +const USER_SESSIONS_KEY = 'mosaic-user-sessions'; + +export function useUserSessionsQuery() { + const { user } = useUser(); + const { session } = useSession(); + const userId = user?.id; + const sessionId = session?.id; + + const { queryKey, stableKey, authenticated } = useMemo( + () => + __internal_createCacheKeys({ + stablePrefix: USER_SESSIONS_KEY, + authenticated: true, + tracked: { userId, sessionId }, + untracked: { args: {} }, + }), + [userId, sessionId], + ); + + // Only runs while this hook is mounted, matching the shared hooks. Sign-out after unmount leaves the entry until it is garbage collected. + __internal_useClearQueriesOnSignOut({ + isSignedOut: user === null, + authenticated, + stableKeys: stableKey, + }); + + return __internal_useClerkQuery({ + queryKey, + // getSessions() also caches on the user, so a refetch only gets fresh data once the user resource is rebuilt. + queryFn: () => user?.getSessions(), + staleTime: 2 * 60_000, + enabled: Boolean(user && session), + }); +} diff --git a/packages/shared/src/react/hooks/createCacheKeys.ts b/packages/shared/src/react/hooks/createCacheKeys.ts index e7b6ac7ad09..1bab7973911 100644 --- a/packages/shared/src/react/hooks/createCacheKeys.ts +++ b/packages/shared/src/react/hooks/createCacheKeys.ts @@ -8,7 +8,7 @@ export function createCacheKeys< T extends Record = Record, U extends Record | undefined = undefined, >(params: { - stablePrefix: ResourceCacheStableKey | __internal_ResourceCacheStableKey; + stablePrefix: ResourceCacheStableKey | __internal_ResourceCacheStableKey | `mosaic-${string}`; authenticated: boolean; tracked: T; untracked: U extends { args: Params } ? U : never; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 16adaa48d8c..700275614d8 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -82,6 +82,10 @@ export type { UseOrganizationSSOBypassAllowlistReturn, } from './useOrganizationSSOBypassAllowlist'; +export { createCacheKeys as __internal_createCacheKeys } from './createCacheKeys'; +export { useClearQueriesOnSignOut as __internal_useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +export { useClerkQuery as __internal_useClerkQuery } from '../query/useQuery'; + export { useUserBase as __internal_useUserBase } from './base/useUserBase'; export { useClientBase as __internal_useClientBase } from './base/useClientBase'; export { useSessionBase as __internal_useSessionBase } from './base/useSessionBase';