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';