diff --git a/.changeset/early-clowns-dance.md b/.changeset/early-clowns-dance.md new file mode 100644 index 00000000000..2640543ddfb --- /dev/null +++ b/.changeset/early-clowns-dance.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Removes SWR as direct dependency diff --git a/.changeset/fuzzy-donuts-jam.md b/.changeset/fuzzy-donuts-jam.md new file mode 100644 index 00000000000..593efe22d1e --- /dev/null +++ b/.changeset/fuzzy-donuts-jam.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Creates compatibility layer for SWR hooks that were previously inside `@clerk/clerk-js` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557276e735e..608ed268986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,7 +310,6 @@ jobs: 'vue', 'nuxt', 'react-router', - 'machine', 'custom', ] test-project: ["chrome"] @@ -321,6 +320,12 @@ jobs: - test-name: 'billing' test-project: 'chrome' clerk-use-rq: 'true' + - test-name: 'machine' + test-project: 'chrome' + clerk-use-rq: 'false' + - test-name: 'machine' + test-project: 'chrome' + clerk-use-rq: 'true' - test-name: 'nextjs' test-project: 'chrome' next-version: '14' diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index df3845fd0ed..f03881e4632 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -344,4 +344,376 @@ testAgainstRunningApps({ await u.page.unrouteAll(); }); + + test.describe('api key list invalidation', () => { + // Helper function to count actual API key rows (not empty state) + const createAPIKeyCountHelper = (u: any) => async () => { + // Wait for the table to be fully loaded first + await u.page.locator('.cl-apiKeysTable').waitFor({ timeout: 10000 }); + + // Wait for any ongoing navigation/pagination to complete by waiting for network idle + await u.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + // Ignore timeout - continue with other checks + }); + + // Wait for content to stabilize - check multiple times to ensure consistency + let stableCount = -1; + let retryCount = 0; + const maxRetries = 10; + + while (retryCount < maxRetries) { + // Wait for content to load (either empty state or actual data) + await u.page + .waitForFunction( + () => { + const emptyText = document.querySelector( + 'text[data-localization-key*="emptyRow"], [data-localization-key*="emptyRow"]', + ); + const menuButtons = document.querySelectorAll( + '.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton', + ); + const spinner = document.querySelector('.cl-spinner'); + + // Content is loaded if we have either empty state, menu buttons, or no spinner + return emptyText || menuButtons.length > 0 || !spinner; + }, + { timeout: 3000 }, + ) + .catch(() => { + // Continue to next check if this fails + }); + + // Check if spinner is still visible (still loading) + const spinner = u.page.locator('.cl-spinner'); + if (await spinner.isVisible().catch(() => false)) { + await spinner.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => { + // Continue if spinner doesn't disappear + }); + } + + // Check for empty state first + const emptyStateText = await u.page + .getByText('No API keys found') + .isVisible() + .catch(() => false); + if (emptyStateText) { + return 0; + } + + // Count menu buttons (each API key row has one) + const menuButtons = u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton'); + const currentCount = await menuButtons.count(); + + // Check if count has stabilized (same as previous check) + if (currentCount === stableCount) { + return currentCount; + } + + stableCount = currentCount; + retryCount++; + + // Small delay before next check to allow for DOM updates + if (retryCount < maxRetries) { + await u.page.waitForTimeout(200); + } + } + + // Return the last stable count if we've exhausted retries + return stableCount; + }; + + test('api key list invalidation: new key appears immediately after creation', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + const initialRowCount = await getAPIKeyCount(); + + // Create a new API key with unique name + const newApiKeyName = `invalidation-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + // Close copy modal + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify the new API key appears in the list immediately (invalidation worked) + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible({ timeout: 5000 }); + + // Verify the total count increased + const finalRowCount = await getAPIKeyCount(); + expect(finalRowCount).toBe(initialRowCount + 1); + + // Clean up - revoke the API key created in this test to avoid interfering with other tests + const menuButton = table.locator('.cl-tableRow', { hasText: newApiKeyName }).locator('.cl-menuButton'); + await menuButton.click(); + const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); + await revokeButton.click(); + await u.po.apiKeys.waitForRevokeModalOpened(); + await u.po.apiKeys.typeRevokeConfirmation('Revoke'); + await u.po.apiKeys.clickConfirmRevokeButton(); + await u.po.apiKeys.waitForRevokeModalClosed(); + }); + + test('api key list invalidation: pagination info updates after creation', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Create a dedicated user for this test to ensure clean state + const dedicatedUser = u.services.users.createFakeUser(); + const bapiUser = await u.services.users.createBapiUser(dedicatedUser); + + // Create exactly 9 API keys for this user (not using shared organization) + const existingKeys = await Promise.all( + Array.from({ length: 9 }, () => u.services.users.createFakeAPIKey(bapiUser.id)), + ); + + // Sign in with the dedicated user + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: dedicatedUser.email, + password: dedicatedUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Verify we have 9 keys and no pagination (all fit in first page) + // The helper function already has robust waiting logic + const actualCount = await getAPIKeyCount(); + expect(actualCount).toBe(9); + await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden(); + + // Create the 10th API key which should not trigger pagination yet + const newApiKeyName = `boundary-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify we now have 10 keys and still no pagination (exactly fits in one page) + expect(await getAPIKeyCount()).toBe(10); + await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden(); + + // Create the 11th API key which should trigger pagination + const eleventhKeyName = `pagination-trigger-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(eleventhKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify pagination info appears and shows correct count (invalidation updated pagination) + await expect(u.page.getByText(/Displaying 1 – 10 of 11/i)).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(10); + + // Cleanup - revoke the API keys created for this test and delete the user + await Promise.all(existingKeys.map(key => key.revoke())); + await dedicatedUser.deleteIfExists(); + }); + + test('api key list invalidation: works with active search filter', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Create a specific search term that will match our new key + const searchTerm = 'search-test'; + const newApiKeyName = `${searchTerm}-${Date.now()}`; + + // Apply search filter first + const searchInput = u.page.locator('input.cl-apiKeysSearchInput'); + await searchInput.fill(searchTerm); + + // Wait for search to be applied (debounced) - wait for empty state or results + await u.page.waitForFunction( + () => { + const emptyMessage = document.querySelector('[data-localization-key*="emptyRow"]'); + const hasResults = + document.querySelectorAll('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton').length > 0; + return emptyMessage || hasResults; + }, + { timeout: 2000 }, + ); + + // Verify no results initially match + expect(await getAPIKeyCount()).toBe(0); + + // Create API key that matches the search + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify the new key appears in filtered results (invalidation worked with search) + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(1); + + // Clear search and verify key appears in full list too + await searchInput.clear(); + // Wait for search to clear and show all results + await u.page.waitForFunction( + () => { + return document.querySelectorAll('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton').length > 0; + }, + { timeout: 2000 }, + ); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible(); + }); + + test('api key list invalidation: works when on second page of results', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Create a dedicated user for this test to ensure clean state + const dedicatedUser = u.services.users.createFakeUser(); + const bapiUser = await u.services.users.createBapiUser(dedicatedUser); + + // Create exactly 15 API keys for this user to have 2 pages (10 per page) + const existingKeys = await Promise.all( + Array.from({ length: 15 }, () => u.services.users.createFakeAPIKey(bapiUser.id)), + ); + + // Sign in with the dedicated user + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: dedicatedUser.email, + password: dedicatedUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Verify pagination and go to second page + await expect(u.page.getByText(/Displaying 1 – 10 of 15/i)).toBeVisible(); + const page2Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^2$/ }); + await page2Button.click(); + await expect(u.page.getByText(/Displaying 11 – 15 of 15/i)).toBeVisible(); + expect(await getAPIKeyCount()).toBe(5); + + // Create a new API key while on page 2 + const newApiKeyName = `page2-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify pagination info updated (invalidation refreshed all pages) + await expect(u.page.getByText(/Displaying 11 – 16 of 16/i)).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(6); + + // The new key should appear on page 1 since it's the most recent + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible(); + + // Cleanup - revoke the API keys created for this test and delete the user + await Promise.all(existingKeys.map(key => key.revoke())); + await dedicatedUser.deleteIfExists(); + }); + + test('api key list invalidation: multiple rapid creations update correctly', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + const initialRowCount = await getAPIKeyCount(); + const timestamp = Date.now(); + + // Create multiple API keys rapidly to test invalidation handles concurrent updates + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(keyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + } + + // Verify all 3 new keys appear in the list + const table = u.page.locator('.cl-apiKeysTable'); + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + await expect(table.locator('.cl-tableRow', { hasText: keyName })).toBeVisible({ timeout: 5000 }); + } + + // Verify total count increased by 3 + const finalRowCount = await getAPIKeyCount(); + expect(finalRowCount).toBe(initialRowCount + 3); + + // Clean up - revoke the API keys created in this test to avoid interfering with other tests + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + const menuButton = table.locator('.cl-tableRow', { hasText: keyName }).locator('.cl-menuButton'); + await menuButton.click(); + const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); + await revokeButton.click(); + await u.po.apiKeys.waitForRevokeModalOpened(); + await u.po.apiKeys.typeRevokeConfirmation('Revoke'); + await u.po.apiKeys.clickConfirmRevokeButton(); + await u.po.apiKeys.waitForRevokeModalClosed(); + } + }); + }); }); diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index a1653b140c5..cd8ad69e3cb 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -82,8 +82,7 @@ "dequal": "2.0.3", "input-otp": "1.4.2", "qrcode.react": "4.2.0", - "regenerator-runtime": "0.14.1", - "swr": "2.3.4" + "regenerator-runtime": "0.14.1" }, "devDependencies": { "@clerk/testing": "workspace:^", diff --git a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx index 0ab90e2cc05..f60898e19d2 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx @@ -1,8 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { __experimental_useAPIKeys as useAPIKeys, useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { CreateAPIKeyParams } from '@clerk/shared/types'; +import type { APIKeyResource } from '@clerk/shared/types'; import { lazy, useState } from 'react'; -import useSWRMutation from 'swr/mutation'; import { useProtect } from '@/ui/common'; import { useAPIKeysContext, withCoreUserGuard } from '@/ui/contexts'; @@ -69,6 +68,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr fetchPage, pageCount, count: itemCount, + revalidate: invalidateAll, } = useAPIKeys({ subject, pageSize: perPage ?? API_KEYS_PAGE_SIZE, @@ -77,12 +77,11 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr enabled: isOrg ? canReadAPIKeys : true, }); - const { invalidateAll } = useAPIKeysPagination({ + useAPIKeysPagination({ query, page, pageCount, isFetching, - subject, fetchPage, }); @@ -94,11 +93,9 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr }; const card = useCardState(); const clerk = useClerk(); - const { - data: createdAPIKey, - trigger: createAPIKey, - isMutating, - } = useSWRMutation('api-keys-create', (_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg)); + + const [apiKey, setAPIKey] = useState(null); + const { t } = useLocalizations(); const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false); const [selectedAPIKeyID, setSelectedAPIKeyID] = useState(''); @@ -107,19 +104,23 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr const handleCreateAPIKey = async (params: OnCreateParams) => { try { - await createAPIKey({ + card.setLoading(); + const apiKey = await clerk.apiKeys.create({ ...params, subject, }); invalidateAll(); card.setError(undefined); setIsCopyModalOpen(true); + setAPIKey(apiKey); } catch (err: any) { if (isClerkAPIResponseError(err)) { if (err.status === 409) { card.setError('API Key name already exists'); } } + } finally { + card.setIdle(); } }; @@ -181,10 +182,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr ({ paddingTop: t.space.$6, paddingBottom: t.space.$6 })}> - + @@ -193,8 +191,8 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr isOpen={isCopyModalOpen} onOpen={() => setIsCopyModalOpen(true)} onClose={() => setIsCopyModalOpen(false)} - apiKeyName={createdAPIKey?.name ?? ''} - apiKeySecret={createdAPIKey?.secret ?? ''} + apiKeyName={apiKey?.name || ''} + apiKeySecret={apiKey?.secret || ''} modalRoot={revokeModalRoot} /> diff --git a/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx b/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx index da603098143..4b2caa80c1c 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { useAPIKeysContext } from '@/ui/contexts'; import { Box, Col, descriptors, FormLabel, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; import { useActionContext } from '@/ui/elements/Action/ActionRoot'; +import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; @@ -28,7 +29,6 @@ export type OnCreateParams = { interface CreateAPIKeyFormProps { onCreate: (params: OnCreateParams) => void; - isSubmitting: boolean; } const EXPIRATION_DURATIONS: Record, (date: Date) => void> = { @@ -117,10 +117,11 @@ const ExpirationSelector: React.FC = ({ selectedExpirat ); }; -export const CreateAPIKeyForm: React.FC = ({ onCreate, isSubmitting }) => { +export const CreateAPIKeyForm: React.FC = ({ onCreate }) => { const [selectedExpiration, setSelectedExpiration] = useState(null); const { close: closeCardFn } = useActionContext(); const { showDescription = false } = useAPIKeysContext(); + const card = useCardState(); const { t } = useLocalizations(); const nameField = useFormControl('name', '', { @@ -251,7 +252,7 @@ export const CreateAPIKeyForm: React.FC = ({ onCreate, is submitLabel={localizationKeys('apiKeys.formButtonPrimary__add')} isDisabled={!canSubmit} onReset={closeCardFn} - isLoading={isSubmitting} + isLoading={card.isLoading} elementDescriptor={descriptors.apiKeysCreateFormSubmitButton} /> diff --git a/packages/clerk-js/src/ui/components/APIKeys/utils.ts b/packages/clerk-js/src/ui/components/APIKeys/utils.ts index 5eb2657c558..9cf18c59b69 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/utils.ts +++ b/packages/clerk-js/src/ui/components/APIKeys/utils.ts @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { useSWRConfig } from 'swr'; +import { useEffect, useRef } from 'react'; type UseAPIKeysPaginationParams = { query: string; page: number; pageCount: number; isFetching: boolean; - subject: string; fetchPage: (page: number) => void; }; @@ -16,27 +14,7 @@ type UseAPIKeysPaginationParams = { * - Adjusts page when current page exceeds available pages (e.g., after deletion) * - Provides cache invalidation function for mutations */ -export const useAPIKeysPagination = ({ - query, - page, - pageCount, - isFetching, - subject, - fetchPage, -}: UseAPIKeysPaginationParams) => { - const { mutate } = useSWRConfig(); - - // Invalidate all cache entries for this user or organization - const invalidateAll = useCallback(() => { - void mutate(key => { - if (!key || typeof key !== 'object') { - return false; - } - // Match all apiKeys cache entries for this user or organization, regardless of page, pageSize, or query - return 'type' in key && key.type === 'apiKeys' && 'subject' in key && key.subject === subject; - }); - }, [mutate, subject]); - +export const useAPIKeysPagination = ({ query, page, pageCount, isFetching, fetchPage }: UseAPIKeysPaginationParams) => { // Reset to first page when query changes const previousQueryRef = useRef(query); useEffect(() => { @@ -53,8 +31,4 @@ export const useAPIKeysPagination = ({ fetchPage(Math.max(1, pageCount)); } }, [pageCount, page, isFetching, fetchPage]); - - return { - invalidateAll, - }; }; diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index 8574c783daf..ca4671fd3ac 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,6 +1,5 @@ -import { useClerk, useOrganizationContext } from '@clerk/shared/react'; +import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; import type { BillingSubscriptionItemResource } from '@clerk/shared/types'; -import useSWR from 'swr'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; @@ -31,28 +30,17 @@ export const PaymentAttemptPage = () => { const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const { t, translateError } = useLocalizations(); - const clerk = useClerk(); - // Do not use `useOrganization` to avoid triggering the in-app enable organizations prompt in development instance - const organizationCtx = useOrganizationContext(); + const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { data: paymentAttempt, isLoading, error, - } = useSWR( - params.paymentAttemptId - ? { - type: 'payment-attempt', - id: params.paymentAttemptId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - } - : null, - () => - clerk.billing.getPaymentAttempt({ - id: params.paymentAttemptId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - }), - ); + } = __internal_usePaymentAttemptQuery({ + paymentAttemptId: params.paymentAttemptId, + for: requesterType, + enabled: Boolean(params.paymentAttemptId), + }); const subscriptionItem = paymentAttempt?.subscriptionItem; diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index f9bf3b931c3..ba5bde2d969 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -1,4 +1,4 @@ -import { useClerk } from '@clerk/shared/react'; +import { __internal_usePlanDetailsQuery } from '@clerk/shared/react/index'; import type { __internal_PlanDetailsProps, BillingPlanResource, @@ -7,7 +7,6 @@ import type { } from '@clerk/shared/types'; import * as React from 'react'; import { useMemo, useState } from 'react'; -import useSWR from 'swr'; import { Alert } from '@/ui/elements/Alert'; import { Avatar } from '@/ui/elements/Avatar'; @@ -79,24 +78,17 @@ const PlanDetailsInternal = ({ plan: initialPlan, initialPlanPeriod = 'month', }: __internal_PlanDetailsProps) => { - const clerk = useClerk(); const [planPeriod, setPlanPeriod] = useState(initialPlanPeriod); const { data: plan, isLoading, error, - } = useSWR( - planId || initialPlan ? { type: 'plan', id: planId || initialPlan?.id } : null, - // @ts-expect-error we are handling it above - () => clerk.billing.getPlan({ id: planId || initialPlan?.id }), - { - fallbackData: initialPlan, - revalidateOnFocus: false, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); + } = __internal_usePlanDetailsQuery({ + planId, + initialPlan, + enabled: Boolean(planId || initialPlan?.id), + }); if (isLoading && !initialPlan) { return ( diff --git a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx index fab81348ba5..d87e182b054 100644 --- a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx @@ -70,6 +70,7 @@ describe('PlanDetails', () => { it('displays spinner when loading with planId', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getPlan.mockImplementation(() => new Promise(() => {})); @@ -118,6 +119,7 @@ describe('PlanDetails', () => { it('fetches and renders plan details when planId is provided', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getPlan.mockResolvedValue(mockPlan); diff --git a/packages/clerk-js/src/ui/components/Statements/StatementPage.tsx b/packages/clerk-js/src/ui/components/Statements/StatementPage.tsx index 03b6f933568..c5571e4f7ef 100644 --- a/packages/clerk-js/src/ui/components/Statements/StatementPage.tsx +++ b/packages/clerk-js/src/ui/components/Statements/StatementPage.tsx @@ -1,5 +1,4 @@ -import { useClerk, useOrganizationContext } from '@clerk/shared/react'; -import useSWR from 'swr'; +import { __internal_useStatementQuery } from '@clerk/shared/react/index'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; @@ -25,28 +24,17 @@ export const StatementPage = () => { const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const { t, translateError } = useLocalizations(); - const clerk = useClerk(); - // Do not use `useOrganization` to avoid triggering the in-app enable organizations prompt in development instance - const organizationCtx = useOrganizationContext(); + const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { data: statement, isLoading, error, - } = useSWR( - params.statementId - ? { - type: 'statement', - id: params.statementId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - } - : null, - () => - clerk.billing.getStatement({ - id: params.statementId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - }), - ); + } = __internal_useStatementQuery({ + statementId: params.statementId, + for: requesterType, + enabled: Boolean(params.statementId), + }); if (isLoading) { return ( diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx similarity index 65% rename from packages/shared/src/react/__tests__/commerce.test.tsx rename to packages/shared/src/react/__tests__/payment-element.test.tsx index 53d7540af7e..67b2e7b8674 100644 --- a/packages/shared/src/react/__tests__/commerce.test.tsx +++ b/packages/shared/src/react/__tests__/payment-element.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce'; -import { OptionsContext } from '../contexts'; +import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../billing/payment-element'; +import { ClerkInstanceContext, OptionsContext, OrganizationProvider, UserContext } from '../contexts'; // Mock the Stripe components vi.mock('../stripe-react', () => ({ @@ -44,7 +44,7 @@ vi.mock('../hooks/useUser', () => ({ useUser: () => ({ user: { id: 'user_123', - initializePaymentSource: vi.fn().mockResolvedValue({ + initializePaymentMethod: vi.fn().mockResolvedValue({ externalGatewayId: 'acct_123', externalClientSecret: 'seti_123', paymentMethodOrder: ['card'], @@ -59,25 +59,35 @@ vi.mock('../hooks/useOrganization', () => ({ }), })); -vi.mock('swr', () => ({ - __esModule: true, - default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }), -})); +const mockInitializePaymentMethod = vi.fn().mockResolvedValue({ + externalGatewayId: 'acct_123', + externalClientSecret: 'seti_123', + paymentMethodOrder: ['card'], +}); -vi.mock('swr/mutation', () => ({ - __esModule: true, - default: () => ({ - data: { +vi.mock('../billing/useInitializePaymentMethod', () => ({ + __internal_useInitializePaymentMethod: vi.fn(() => ({ + initializedPaymentMethod: { externalGatewayId: 'acct_123', externalClientSecret: 'seti_123', paymentMethodOrder: ['card'], }, - trigger: vi.fn().mockResolvedValue({ - externalGatewayId: 'acct_123', - externalClientSecret: 'seti_123', - paymentMethodOrder: ['card'], - }), - }), + initializePaymentMethod: mockInitializePaymentMethod, + })), +})); + +const mockStripeLibs = { + loadStripe: vi.fn().mockResolvedValue({}), +}; + +vi.mock('../billing/useStripeClerkLibs', () => ({ + __internal_useStripeClerkLibs: vi.fn(() => mockStripeLibs), +})); + +const mockStripeInstance = {} as any; + +vi.mock('../billing/useStripeLoader', () => ({ + __internal_useStripeLoader: vi.fn(() => mockStripeInstance), })); describe('PaymentElement Localization', () => { @@ -158,6 +168,27 @@ describe('PaymentElement Localization', () => { }, }; + const mockClerk = { + __internal_loadStripeJs: vi.fn().mockResolvedValue(() => Promise.resolve({})), + __internal_getOption: mockGetOption, + __unstable__environment: { + commerceSettings: { + billing: { + stripePublishableKey: 'pk_test_123', + }, + }, + displayConfig: { + userProfileUrl: 'https://example.com/profile', + organizationProfileUrl: 'https://example.com/org-profile', + }, + }, + }; + + const mockUser = { + id: 'user_123', + initializePaymentMethod: mockInitializePaymentMethod, + }; + const renderWithLocale = (locale: string) => { // Mock the __internal_getOption to return the expected localization mockGetOption.mockImplementation(key => { @@ -172,11 +203,17 @@ describe('PaymentElement Localization', () => { }; return render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); }; @@ -199,11 +236,17 @@ describe('PaymentElement Localization', () => { const options = {}; render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); const elements = screen.getByTestId('stripe-elements'); @@ -235,11 +278,17 @@ describe('PaymentElement Localization', () => { }; const { unmount } = render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); const elements = screen.getByTestId('stripe-elements'); diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/billing/payment-element.tsx similarity index 79% rename from packages/shared/src/react/commerce.tsx rename to packages/shared/src/react/billing/payment-element.tsx index 5d5e59e0906..dbfbac4dd89 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/billing/payment-element.tsx @@ -1,18 +1,17 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ import type { Stripe, StripeElements, StripeElementsOptions } from '@stripe/stripe-js'; -import React, { type PropsWithChildren, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; - -import type { BillingCheckoutResource, EnvironmentResource, ForPayerType } from '../types'; -import { createContextAndHook } from './hooks/createContextAndHook'; -import type { useCheckout } from './hooks/useCheckout'; -import { useClerk } from './hooks/useClerk'; -import { useOrganization } from './hooks/useOrganization'; -import { useUser } from './hooks/useUser'; -import { Elements, PaymentElement as StripePaymentElement, useElements, useStripe } from './stripe-react'; - -type LoadStripeFn = typeof import('@stripe/stripe-js').loadStripe; +import React, { type PropsWithChildren, type ReactNode, useCallback, useMemo, useState } from 'react'; + +import type { BillingCheckoutResource, EnvironmentResource, ForPayerType } from '../../types'; +import { createContextAndHook } from '../hooks/createContextAndHook'; +import type { useCheckout } from '../hooks/useCheckout'; +import { useClerk } from '../hooks/useClerk'; +import { Elements, PaymentElement as StripePaymentElement, useElements, useStripe } from '../stripe-react'; +import { + __internal_useInitializePaymentMethod as useInitializePaymentMethod, + type UseInitializePaymentMethodResult, +} from './useInitializePaymentMethod'; +import { __internal_useStripeClerkLibs as useStripeClerkLibs } from './useStripeClerkLibs'; +import { __internal_useStripeLoader as useStripeLoader, type UseStripeLoaderResult } from './useStripeLoader'; type PaymentElementError = { gateway: 'stripe'; @@ -26,36 +25,6 @@ type PaymentElementError = { }; }; -const [StripeLibsContext, useStripeLibsContext] = createContextAndHook<{ - loadStripe: LoadStripeFn; -} | null>('StripeLibsContext'); - -const StripeLibsProvider = ({ children }: PropsWithChildren) => { - const clerk = useClerk(); - const { data: stripeClerkLibs } = useSWR( - 'clerk-stripe-sdk', - async () => { - const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; - return { loadStripe }; - }, - { - keepPreviousData: true, - revalidateOnFocus: false, - dedupingInterval: Infinity, - }, - ); - - return ( - - {children} - - ); -}; - const useInternalEnvironment = () => { const clerk = useClerk(); // @ts-expect-error `__unstable__environment` is not typed @@ -80,54 +49,22 @@ const useLocalization = () => { }; const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { - const { organization } = useOrganization(); - const { user } = useUser(); - const resource = forResource === 'organization' ? organization : user; - const stripeClerkLibs = useStripeLibsContext(); - - const { data: initializedPaymentMethod, trigger: initializePaymentMethod } = useSWRMutation( - { - key: 'billing-payment-method-initialize', - resourceId: resource?.id, - }, - () => { - return resource?.initializePaymentMethod({ - gateway: 'stripe', - }); - }, - ); - + const stripeClerkLibs = useStripeClerkLibs(); const environment = useInternalEnvironment(); - useEffect(() => { - if (!resource?.id) { - return; - } - initializePaymentMethod().catch(() => { - // ignore errors - }); - }, [resource?.id]); + const { initializedPaymentMethod, initializePaymentMethod }: UseInitializePaymentMethodResult = + useInitializePaymentMethod({ for: forResource }); + + const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey ?? undefined; + + const stripe: UseStripeLoaderResult = useStripeLoader({ + stripeClerkLibs, + externalGatewayId: initializedPaymentMethod?.externalGatewayId, + stripePublishableKey, + }); - const externalGatewayId = initializedPaymentMethod?.externalGatewayId; const externalClientSecret = initializedPaymentMethod?.externalClientSecret; const paymentMethodOrder = initializedPaymentMethod?.paymentMethodOrder; - const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey; - - const { data: stripe } = useSWR( - stripeClerkLibs && externalGatewayId && stripePublishableKey - ? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey } - : null, - ({ stripePublishableKey, externalGatewayId }) => { - return stripeClerkLibs?.loadStripe(stripePublishableKey, { - stripeAccount: externalGatewayId, - }); - }, - { - keepPreviousData: true, - revalidateOnFocus: false, - dedupingInterval: 1_000 * 60, // 1 minute - }, - ); return { stripe, @@ -225,11 +162,9 @@ const PropsProvider = ({ children, ...props }: PropsWithChildren) => { return ( - - - {children} - - + + {children} + ); }; diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx new file mode 100644 index 00000000000..5c58075c6b6 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from 'react'; + +import type { BillingInitializedPaymentMethodResource, ForPayerType } from '../../types'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; + +type InitializePaymentMethodOptions = { + for?: ForPayerType; +}; + +export type UseInitializePaymentMethodResult = { + initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; + initializePaymentMethod: () => Promise; +}; + +/** + * @internal + */ +function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { + const { for: forType } = options ?? {}; + const { organization } = useOrganizationContext(); + const user = useUserContext(); + + const resource = forType === 'organization' ? organization : user; + + const billingEnabled = useBillingHookEnabled(options); + + const queryKey = useMemo(() => { + return ['billing-payment-method-initialize', { resourceId: resource?.id }] as const; + }, [resource?.id]); + + const isEnabled = Boolean(resource?.id) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: async () => { + if (!resource) { + return undefined; + } + + return resource.initializePaymentMethod({ + gateway: 'stripe', + }); + }, + enabled: isEnabled, + staleTime: 1_000 * 60, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + const [queryClient] = useClerkQueryClient(); + + const initializePaymentMethod = useCallback(async () => { + if (!resource) { + return undefined; + } + + const result = await resource.initializePaymentMethod({ + gateway: 'stripe', + }); + + queryClient.setQueryData(queryKey, result); + + return result; + }, [queryClient, queryKey, resource]); + + return { + initializedPaymentMethod: query.data ?? undefined, + initializePaymentMethod, + }; +} + +export { useInitializePaymentMethod as __internal_useInitializePaymentMethod }; diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx new file mode 100644 index 00000000000..8a4a3df8f35 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import useSWRMutation from 'swr/mutation'; + +import type { BillingInitializedPaymentMethodResource, ForPayerType } from '../../types'; +import { useOrganizationContext, useUserContext } from '../contexts'; + +type InitializePaymentMethodOptions = { + for?: ForPayerType; +}; + +export type UseInitializePaymentMethodResult = { + initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; + initializePaymentMethod: () => Promise; +}; + +/** + * This is the existing implementation of the payment method initializer using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { + const { for: forType = 'user' } = options ?? {}; + const { organization } = useOrganizationContext(); + const user = useUserContext(); + + const resource = forType === 'organization' ? organization : user; + + const { data, trigger } = useSWRMutation( + resource?.id + ? { + key: 'billing-payment-method-initialize', + resourceId: resource.id, + for: forType, + } + : null, + () => { + return resource?.initializePaymentMethod({ + gateway: 'stripe', + }); + }, + ); + + useEffect(() => { + if (!resource?.id) { + return; + } + + trigger().catch(() => { + // ignore errors + }); + }, [resource?.id, trigger]); + + return { + initializedPaymentMethod: data, + initializePaymentMethod: trigger, + }; +} + +export { useInitializePaymentMethod as __internal_useInitializePaymentMethod }; diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.tsx new file mode 100644 index 00000000000..1373b76c409 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.tsx @@ -0,0 +1,2 @@ +export type { UseInitializePaymentMethodResult } from 'virtual:data-hooks/useInitializePaymentMethod'; +export { __internal_useInitializePaymentMethod } from 'virtual:data-hooks/useInitializePaymentMethod'; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx new file mode 100644 index 00000000000..e2dd394b24c --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx @@ -0,0 +1,37 @@ +import type { loadStripe } from '@stripe/stripe-js'; + +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; +import { useClerk } from '../hooks/useClerk'; + +type LoadStripeFn = typeof loadStripe; + +type StripeClerkLibs = { + loadStripe: LoadStripeFn; +}; + +/** + * @internal + */ +function useStripeClerkLibs(): StripeClerkLibs | null { + const clerk = useClerk(); + + const billingEnabled = useBillingHookEnabled(); + + const query = useClerkQuery({ + queryKey: ['clerk-stripe-sdk'], + queryFn: async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + return { loadStripe }; + }, + enabled: billingEnabled, + staleTime: Infinity, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + return query.data ?? null; +} + +export { useStripeClerkLibs as __internal_useStripeClerkLibs }; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx new file mode 100644 index 00000000000..820144b4dff --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx @@ -0,0 +1,39 @@ +import type { loadStripe } from '@stripe/stripe-js'; + +import { useSWR } from '../clerk-swr'; +import { useClerk } from '../hooks/useClerk'; + +type LoadStripeFn = typeof loadStripe; + +type StripeClerkLibs = { + loadStripe: LoadStripeFn; +}; + +export type UseStripeClerkLibsResult = StripeClerkLibs | null; + +/** + * This is the existing implementation of the Stripe libraries loader using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useStripeClerkLibs(): UseStripeClerkLibsResult { + const clerk = useClerk(); + + const swr = useSWR( + 'clerk-stripe-sdk', + async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + return { loadStripe }; + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: Infinity, + }, + ); + + return swr.data ?? null; +} + +export { useStripeClerkLibs as __internal_useStripeClerkLibs }; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.tsx new file mode 100644 index 00000000000..3a55aaca025 --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.tsx @@ -0,0 +1,2 @@ +export type { UseStripeClerkLibsResult } from 'virtual:data-hooks/useStripeClerkLibs'; +export { __internal_useStripeClerkLibs } from 'virtual:data-hooks/useStripeClerkLibs'; diff --git a/packages/shared/src/react/billing/useStripeLoader.rq.tsx b/packages/shared/src/react/billing/useStripeLoader.rq.tsx new file mode 100644 index 00000000000..59dee615f6b --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.rq.tsx @@ -0,0 +1,51 @@ +import type { Stripe } from '@stripe/stripe-js'; +import { useMemo } from 'react'; + +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; +import type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; + +type StripeLoaderOptions = { + stripeClerkLibs: UseStripeClerkLibsResult; + externalGatewayId?: string; + stripePublishableKey?: string; +}; + +export type UseStripeLoaderResult = Stripe | null | undefined; + +/** + * @internal + */ +function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { + const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; + + const queryKey = useMemo(() => { + return ['stripe-sdk', { externalGatewayId, stripePublishableKey }] as const; + }, [externalGatewayId, stripePublishableKey]); + + const billingEnabled = useBillingHookEnabled({ authenticated: true }); + + const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!stripeClerkLibs || !externalGatewayId || !stripePublishableKey) { + return null; + } + + return stripeClerkLibs.loadStripe(stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + }, + enabled: isEnabled, + staleTime: 1_000 * 60, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + return query.data; +} + +export { useStripeLoader as __internal_useStripeLoader }; diff --git a/packages/shared/src/react/billing/useStripeLoader.swr.tsx b/packages/shared/src/react/billing/useStripeLoader.swr.tsx new file mode 100644 index 00000000000..57e396dcddc --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.swr.tsx @@ -0,0 +1,46 @@ +import type { Stripe } from '@stripe/stripe-js'; + +import { useSWR } from '../clerk-swr'; +import type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; + +type StripeLoaderOptions = { + stripeClerkLibs: UseStripeClerkLibsResult; + externalGatewayId?: string; + stripePublishableKey?: string; +}; + +export type UseStripeLoaderResult = Stripe | null | undefined; + +/** + * This is the existing implementation of the Stripe instance loader using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { + const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; + + const swr = useSWR( + stripeClerkLibs && externalGatewayId && stripePublishableKey + ? { + key: 'stripe-sdk', + externalGatewayId, + stripePublishableKey, + } + : null, + ({ stripePublishableKey, externalGatewayId }) => { + return stripeClerkLibs?.loadStripe(stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: 1_000 * 60, + }, + ); + + return swr.data; +} + +export { useStripeLoader as __internal_useStripeLoader }; diff --git a/packages/shared/src/react/billing/useStripeLoader.tsx b/packages/shared/src/react/billing/useStripeLoader.tsx new file mode 100644 index 00000000000..689fed791c4 --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.tsx @@ -0,0 +1,2 @@ +export type { UseStripeLoaderResult } from 'virtual:data-hooks/useStripeLoader'; +export { __internal_useStripeLoader } from 'virtual:data-hooks/useStripeLoader'; diff --git a/packages/shared/src/react/clerk-rq/keep-previous-data.ts b/packages/shared/src/react/clerk-rq/keep-previous-data.ts new file mode 100644 index 00000000000..157809c1ae9 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/keep-previous-data.ts @@ -0,0 +1,11 @@ +/** + * @internal + */ +export function defineKeepPreviousDataFn(enabled: boolean) { + if (enabled) { + return function KeepPreviousDataFn(previousData: Data): Data { + return previousData; + }; + } + return undefined; +} diff --git a/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts index 8f9104a7145..2f949336de4 100644 --- a/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts +++ b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts @@ -1,5 +1,3 @@ -'use client'; - import type { DefaultError, InfiniteData, QueryKey, QueryObserver } from '@tanstack/query-core'; import { InfiniteQueryObserver } from '@tanstack/query-core'; diff --git a/packages/shared/src/react/clerk-rq/useQuery.ts b/packages/shared/src/react/clerk-rq/useQuery.ts index 89416b2e24b..cbe0b0d7367 100644 --- a/packages/shared/src/react/clerk-rq/useQuery.ts +++ b/packages/shared/src/react/clerk-rq/useQuery.ts @@ -1,4 +1,3 @@ -'use client'; import type { DefaultError, NoInfer, QueryKey } from '@tanstack/query-core'; import { QueryObserver } from '@tanstack/query-core'; diff --git a/packages/shared/src/react/clerk-swr.ts b/packages/shared/src/react/clerk-swr.ts index 8d15a0948ea..5d03ac36156 100644 --- a/packages/shared/src/react/clerk-swr.ts +++ b/packages/shared/src/react/clerk-swr.ts @@ -1,5 +1,6 @@ 'use client'; +// TODO: Replace these SWR re-exports with react-query equivalents. export * from 'swr'; export { default as useSWR } from 'swr'; diff --git a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx index 1576cccd9be..075b794a406 100644 --- a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx @@ -132,7 +132,7 @@ describe('useApiKeys', () => { if (isRQ) { await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); } else { - expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(2); } }); @@ -170,7 +170,7 @@ describe('useApiKeys', () => { if (isRQ) { await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); } else { - expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(2); } }); diff --git a/packages/shared/src/react/hooks/__tests__/wrapper.tsx b/packages/shared/src/react/hooks/__tests__/wrapper.tsx index 8ee95636f06..90fad9e4a39 100644 --- a/packages/shared/src/react/hooks/__tests__/wrapper.tsx +++ b/packages/shared/src/react/hooks/__tests__/wrapper.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { SWRConfig } from 'swr'; + +import { SWRConfigCompat } from '../../providers/SWRConfigCompat'; export const wrapper = ({ children }: { children: React.ReactNode }) => ( - new Map(), }} > {children} - + ); diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index ffd07b42e30..1bd63a9817e 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -1,5 +1,5 @@ import { eventMethodCalled } from '../../telemetry/events/method-called'; -import type { ClerkPaginatedResponse, ClerkResource, EnvironmentResource, ForPayerType } from '../../types'; +import type { ClerkPaginatedResponse, ClerkResource, ForPayerType } from '../../types'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext, @@ -9,6 +9,7 @@ import { import type { ResourceCacheStableKey } from '../stable-keys'; import type { PagesOrInfiniteOptions, PaginatedHookConfig, PaginatedResources } from '../types'; import { createCacheKeys } from './createCacheKeys'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; /** @@ -98,8 +99,6 @@ export function createBillingPaginatedHook = Record, U extends Record | undefined = undefined, >(params: { - stablePrefix: ResourceCacheStableKey; + stablePrefix: ResourceCacheStableKey | __internal_ResourceCacheStableKey; authenticated: boolean; tracked: T; untracked: U extends { args: Params } ? U : never; @@ -20,3 +21,15 @@ export function createCacheKeys< authenticated: params.authenticated, }; } + +/** + * @internal + */ +export function toSWRQuery }>(keys: T) { + const { queryKey } = keys; + return { + type: queryKey[0], + ...queryKey[2], + ...(queryKey[3] as { args: Record }).args, + }; +} diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index a3774cddeb3..8260af844ec 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -15,3 +15,13 @@ export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaym export { usePlans as __experimental_usePlans } from './usePlans'; export { useSubscription as __experimental_useSubscription } from './useSubscription'; export { useCheckout as __experimental_useCheckout } from './useCheckout'; + +/** + * Internal hooks to be consumed only by `@clerk/clerk-js`. + * These are not considered part of the public API and their query keys can change without notice. + * + * These exist here in order to keep RQ and SWR implementations in a centralized place. + */ +export { __internal_useStatementQuery } from './useStatementQuery'; +export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery'; +export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx new file mode 100644 index 00000000000..05b8962e90f --- /dev/null +++ b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx @@ -0,0 +1,116 @@ +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { APIKeyResource, GetAPIKeysParams } from '../../types'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { STABLE_KEYS } from '../stable-keys'; +import type { PaginatedHookConfig, PaginatedResources } from '../types'; +import { createCacheKeys } from './createCacheKeys'; +import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; + +/** + * @interface + */ +export type UseAPIKeysParams = PaginatedHookConfig< + GetAPIKeysParams & { + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; + } +>; + +/** + * @interface + */ +export type UseAPIKeysReturn = PaginatedResources< + APIKeyResource, + T extends { infinite: true } ? true : false +>; + +/** + * The `useAPIKeys()` hook provides access to paginated API keys for the current user or organization. + * + * @example + * ### Basic usage with default pagination + * + * ```tsx + * const { data, isLoading, page, pageCount, fetchNext, fetchPrevious } = useAPIKeys({ + * subject: 'user_123', + * pageSize: 10, + * initialPage: 1, + * }); + * ``` + * + * @example + * ### With search query + * + * ```tsx + * const [searchValue, setSearchValue] = useState(''); + * const debouncedSearch = useDebounce(searchValue, 500); + * + * const { data, isLoading } = useAPIKeys({ + * subject: 'user_123', + * query: debouncedSearch.trim(), + * pageSize: 10, + * }); + * ``` + * + * @example + * ### Infinite scroll + * + * ```tsx + * const { data, isLoading, fetchNext, hasNextPage } = useAPIKeys({ + * subject: 'user_123', + * infinite: true, + * }); + * ``` + */ +export function useAPIKeys(params?: T): UseAPIKeysReturn { + useAssertWrappedByClerkProvider('useAPIKeys'); + + const safeValues = useWithSafeValues(params, { + initialPage: 1, + pageSize: 10, + keepPreviousData: false, + infinite: false, + subject: '', + query: '', + enabled: true, + } as UseAPIKeysParams); + + const clerk = useClerkInstanceContext(); + + clerk.telemetry?.record(eventMethodCalled('useAPIKeys')); + + const hookParams: GetAPIKeysParams = { + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + ...(safeValues.subject ? { subject: safeValues.subject } : {}), + ...(safeValues.query ? { query: safeValues.query } : {}), + }; + + const isEnabled = (safeValues.enabled ?? true) && clerk.loaded; + + return usePagesOrInfinite({ + fetcher: clerk.apiKeys?.getAll ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll(params) : undefined, + config: { + keepPreviousData: safeValues.keepPreviousData, + infinite: safeValues.infinite, + enabled: isEnabled, + isSignedIn: Boolean(clerk.user), + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + }, + keys: createCacheKeys({ + stablePrefix: STABLE_KEYS.API_KEYS_KEY, + authenticated: Boolean(clerk.user), + tracked: { + subject: safeValues.subject, + }, + untracked: { + args: hookParams, + }, + }), + }) as UseAPIKeysReturn; +} diff --git a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx new file mode 100644 index 00000000000..6696bb49b92 --- /dev/null +++ b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx @@ -0,0 +1,137 @@ +import { useCallback } from 'react'; +import { useSWRConfig } from 'swr'; + +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { APIKeyResource, GetAPIKeysParams } from '../../types'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { STABLE_KEYS } from '../stable-keys'; +import type { PaginatedHookConfig, PaginatedResources } from '../types'; +import { createCacheKeys } from './createCacheKeys'; +import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; + +/** + * @interface + */ +export type UseAPIKeysParams = PaginatedHookConfig< + GetAPIKeysParams & { + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; + } +>; + +/** + * @interface + */ +export type UseAPIKeysReturn = PaginatedResources< + APIKeyResource, + T extends { infinite: true } ? true : false +>; + +/** + * The `useAPIKeys()` hook provides access to paginated API keys for the current user or organization. + * + * @example + * ### Basic usage with default pagination + * + * ```tsx + * const { data, isLoading, page, pageCount, fetchNext, fetchPrevious } = useAPIKeys({ + * subject: 'user_123', + * pageSize: 10, + * initialPage: 1, + * }); + * ``` + * + * @example + * ### With search query + * + * ```tsx + * const [searchValue, setSearchValue] = useState(''); + * const debouncedSearch = useDebounce(searchValue, 500); + * + * const { data, isLoading } = useAPIKeys({ + * subject: 'user_123', + * query: debouncedSearch.trim(), + * pageSize: 10, + * }); + * ``` + * + * @example + * ### Infinite scroll + * + * ```tsx + * const { data, isLoading, fetchNext, hasNextPage } = useAPIKeys({ + * subject: 'user_123', + * infinite: true, + * }); + * ``` + */ +export function useAPIKeys(params?: T): UseAPIKeysReturn { + useAssertWrappedByClerkProvider('useAPIKeys'); + + const safeValues = useWithSafeValues(params, { + initialPage: 1, + pageSize: 10, + keepPreviousData: false, + infinite: false, + subject: '', + query: '', + enabled: true, + } as UseAPIKeysParams); + + const clerk = useClerkInstanceContext(); + + clerk.telemetry?.record(eventMethodCalled('useAPIKeys')); + + const hookParams: GetAPIKeysParams = { + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + ...(safeValues.subject ? { subject: safeValues.subject } : {}), + ...(safeValues.query ? { query: safeValues.query } : {}), + }; + + const isEnabled = (safeValues.enabled ?? true) && clerk.loaded; + + const result = usePagesOrInfinite({ + fetcher: clerk.apiKeys?.getAll ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll(params) : undefined, + config: { + keepPreviousData: safeValues.keepPreviousData, + infinite: safeValues.infinite, + enabled: isEnabled, + isSignedIn: Boolean(clerk.user), + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + }, + keys: createCacheKeys({ + stablePrefix: STABLE_KEYS.API_KEYS_KEY, + authenticated: Boolean(clerk.user), + tracked: { + subject: safeValues.subject, + }, + untracked: { + args: hookParams, + }, + }), + }) as UseAPIKeysReturn; + + const { mutate } = useSWRConfig(); + + // Invalidate all cache entries for this user or organization + const invalidateAll = useCallback(() => { + return mutate(key => { + if (!key || typeof key !== 'object') { + return false; + } + // Match all apiKeys cache entries for this user or organization, regardless of page, pageSize, or query + return 'type' in key && key.type === 'apiKeys' && 'subject' in key && key.subject === safeValues.subject; + }); + }, [mutate, safeValues.subject]); + + return { + ...result, + revalidate: invalidateAll as any, + }; +} diff --git a/packages/shared/src/react/hooks/useAPIKeys.ts b/packages/shared/src/react/hooks/useAPIKeys.ts index d6fc14fdebc..cd899c1e737 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.ts +++ b/packages/shared/src/react/hooks/useAPIKeys.ts @@ -1,118 +1,2 @@ -'use client'; - -import { eventMethodCalled } from '../../telemetry/events/method-called'; -import type { APIKeyResource, GetAPIKeysParams } from '../../types'; -import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; -import type { PaginatedHookConfig, PaginatedResources } from '../types'; -import { createCacheKeys } from './createCacheKeys'; -import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; - -/** - * @interface - */ -export type UseAPIKeysParams = PaginatedHookConfig< - GetAPIKeysParams & { - /** - * If `true`, a request will be triggered when the hook is mounted. - * - * @default true - */ - enabled?: boolean; - } ->; - -/** - * @interface - */ -export type UseAPIKeysReturn = PaginatedResources< - APIKeyResource, - T extends { infinite: true } ? true : false ->; - -/** - * The `useAPIKeys()` hook provides access to paginated API keys for the current user or organization. - * - * @example - * ### Basic usage with default pagination - * - * ```tsx - * const { data, isLoading, page, pageCount, fetchNext, fetchPrevious } = useAPIKeys({ - * subject: 'user_123', - * pageSize: 10, - * initialPage: 1, - * }); - * ``` - * - * @example - * ### With search query - * - * ```tsx - * const [searchValue, setSearchValue] = useState(''); - * const debouncedSearch = useDebounce(searchValue, 500); - * - * const { data, isLoading } = useAPIKeys({ - * subject: 'user_123', - * query: debouncedSearch.trim(), - * pageSize: 10, - * }); - * ``` - * - * @example - * ### Infinite scroll - * - * ```tsx - * const { data, isLoading, fetchNext, hasNextPage } = useAPIKeys({ - * subject: 'user_123', - * infinite: true, - * }); - * ``` - */ -export function useAPIKeys(params?: T): UseAPIKeysReturn { - useAssertWrappedByClerkProvider('useAPIKeys'); - - const safeValues = useWithSafeValues(params, { - initialPage: 1, - pageSize: 10, - keepPreviousData: false, - infinite: false, - subject: '', - query: '', - enabled: true, - } as UseAPIKeysParams); - - const clerk = useClerkInstanceContext(); - - clerk.telemetry?.record(eventMethodCalled('useAPIKeys')); - - const hookParams: GetAPIKeysParams = { - initialPage: safeValues.initialPage, - pageSize: safeValues.pageSize, - ...(safeValues.subject ? { subject: safeValues.subject } : {}), - ...(safeValues.query ? { query: safeValues.query } : {}), - }; - - const isEnabled = (safeValues.enabled ?? true) && clerk.loaded; - - return usePagesOrInfinite({ - fetcher: clerk.apiKeys?.getAll ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll(params) : undefined, - config: { - keepPreviousData: safeValues.keepPreviousData, - infinite: safeValues.infinite, - enabled: isEnabled, - isSignedIn: Boolean(clerk.user), - initialPage: safeValues.initialPage, - pageSize: safeValues.pageSize, - }, - keys: createCacheKeys({ - stablePrefix: STABLE_KEYS.API_KEYS_KEY, - authenticated: Boolean(clerk.user), - tracked: { - subject: safeValues.subject, - }, - untracked: { - args: hookParams, - }, - }), - }) as UseAPIKeysReturn; -} +export { useAPIKeys } from 'virtual:data-hooks/useAPIKeys'; +export type { UseAPIKeysParams, UseAPIKeysReturn } from './useAPIKeys.rq'; diff --git a/packages/shared/src/react/hooks/useBillingHookEnabled.ts b/packages/shared/src/react/hooks/useBillingHookEnabled.ts new file mode 100644 index 00000000000..f52aecfddfb --- /dev/null +++ b/packages/shared/src/react/hooks/useBillingHookEnabled.ts @@ -0,0 +1,27 @@ +import type { ForPayerType } from '../../types/billing'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; + +/** + * @internal + */ +export function useBillingHookEnabled(params?: { for?: ForPayerType; enabled?: boolean; authenticated?: boolean }) { + const clerk = useClerkInstanceContext(); + + const enabledFromParam = params?.enabled ?? true; + + // @ts-expect-error `__unstable__environment` is not typed + const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const isOrganization = params?.for === 'organization'; + const billingEnabled = isOrganization + ? environment?.commerceSettings.billing.organization.enabled + : environment?.commerceSettings.billing.user.enabled; + + const requireUserAndOrganizationWhenAuthenticated = + (params?.authenticated ?? true) ? (isOrganization ? Boolean(organization?.id) : true) && Boolean(user?.id) : true; + + return billingEnabled && enabledFromParam && clerk.loaded && requireUserAndOrganizationWhenAuthenticated; +} diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 79b97cc63fa..146727ebc1d 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -8,9 +8,8 @@ import type { SetActiveNavigate, } from '../../types'; import type { __experimental_CheckoutProvider } from '../contexts'; -import { useCheckoutContext } from '../contexts'; +import { useCheckoutContext, useOrganizationContext } from '../contexts'; import { useClerk } from './useClerk'; -import { useOrganization } from './useOrganization'; import { useUser } from './useUser'; /** @@ -123,7 +122,7 @@ export const useCheckout = (options?: UseCheckoutParams): __experimental_UseChec const { for: forOrganization, planId, planPeriod } = options || contextOptions; const clerk = useClerk(); - const { organization } = useOrganization(); + const { organization } = useOrganizationContext(); const { isLoaded, user } = useUser(); if (!isLoaded) { diff --git a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts index 5fe1d831456..2f4270262f4 100644 --- a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts +++ b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts @@ -10,7 +10,7 @@ type QueryArgs = Readonly<{ args: Params; }>; -type QueryKeyWithArgs = readonly [ +export type QueryKeyWithArgs = readonly [ string, boolean, Record, @@ -27,6 +27,7 @@ export type UsePagesOrInfiniteSignature = < queryKey: QueryKeyWithArgs; invalidationKey: InvalidationQueryKey; stableKey: string; + authenticated: boolean; }, TConfig extends Config = Config, >(params: { diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index ee24a146c09..79d9c6ae96d 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -1,8 +1,7 @@ -'use client'; - import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ClerkPaginatedResponse } from '../../types'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; import { useClerkInfiniteQuery } from '../clerk-rq/useInfiniteQuery'; import { useClerkQuery } from '../clerk-rq/useQuery'; @@ -11,13 +10,6 @@ import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; import { useWithSafeValues } from './usePagesOrInfinite.shared'; import { usePreviousValue } from './usePreviousValue'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { const { fetcher, config, keys } = params; @@ -77,7 +69,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { staleTime: 60_000, enabled: queriesEnabled && !triggerInfinite, // Use placeholderData to keep previous data while fetching new page - placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), }); // Infinite mode: accumulate pages @@ -133,16 +125,52 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { } }, [isSignedIn, queryClient, previousIsSignedIn, forceUpdate]); - const page = useMemo(() => { + // Compute data, count and page from the same data source to ensure consistency + const computedValues = useMemo(() => { if (triggerInfinite) { // Read from query data first, fallback to cache const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - const pages = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; - // Return pages.length if > 0, otherwise return initialPage (default 1) - return pages.length > 0 ? pages.length : initialPageRef.current; + const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); + + // Ensure pages is always an array and filter out null/undefined pages + const validPages = Array.isArray(pages) ? pages.filter(Boolean) : []; + + return { + data: + validPages + .map((a: ClerkPaginatedResponse) => a?.data) + .flat() + .filter(Boolean) ?? [], + count: validPages[validPages.length - 1]?.total_count ?? 0, + page: validPages.length > 0 ? validPages.length : initialPageRef.current, + }; } - return paginatedPage; - }, [triggerInfinite, infiniteQuery.data?.pages, paginatedPage, queryClient, infiniteQueryKey]); + + // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache + // This ensures that after cache clearing, we return consistent empty state + const pageData = queriesEnabled + ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) + : queryClient.getQueryData>(pagesQueryKey); + + return { + data: Array.isArray(pageData?.data) ? pageData.data : [], + count: typeof pageData?.total_count === 'number' ? pageData.total_count : 0, + page: paginatedPage, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- forceUpdateCounter is used to trigger re-renders for cache updates + }, [ + queriesEnabled, + forceUpdateCounter, + triggerInfinite, + infiniteQuery.data?.pages, + singlePageQuery.data, + queryClient, + infiniteQueryKey, + pagesQueryKey, + paginatedPage, + ]); + + const { data, count, page } = computedValues; const fetchPage: ValueOrSetter = useCallback( numberOrgFn => { @@ -163,56 +191,6 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { [infiniteQuery, page, triggerInfinite, queryClient, infiniteQueryKey], ); - const data = useMemo(() => { - if (triggerInfinite) { - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - // When query is disabled, the hook's data is stale, so only read from cache - const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); - return pages.map((a: ClerkPaginatedResponse) => a?.data).flat() ?? []; - } - - // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache - // This ensures that after cache clearing, we return empty data - const pageData = queriesEnabled - ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) - : queryClient.getQueryData>(pagesQueryKey); - return pageData?.data ?? []; - }, [ - queriesEnabled, - forceUpdateCounter, - triggerInfinite, - singlePageQuery.data, - infiniteQuery.data, - queryClient, - pagesQueryKey, - infiniteQueryKey, - ]); - - const count = useMemo(() => { - if (triggerInfinite) { - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - // When query is disabled, the hook's data is stale, so only read from cache - const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); - return pages[pages.length - 1]?.total_count || 0; - } - - // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache - // This ensures that after cache clearing, we return 0 - const pageData = queriesEnabled - ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) - : queryClient.getQueryData>(pagesQueryKey); - return pageData?.total_count ?? 0; - }, [ - queriesEnabled, - forceUpdateCounter, - triggerInfinite, - singlePageQuery.data, - infiniteQuery.data, - queryClient, - pagesQueryKey, - infiniteQueryKey, - ]); - const isLoading = triggerInfinite ? infiniteQuery.isLoading : singlePageQuery.isLoading; const isFetching = triggerInfinite ? infiniteQuery.isFetching : singlePageQuery.isFetching; const error = (triggerInfinite ? infiniteQuery.error : singlePageQuery.error) ?? null; @@ -252,7 +230,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { >; return { ...prevValue, pages: nextPages }; }); - // Force re-render to reflect cache changes + // Force immediate re-render to reflect cache changes forceUpdate(n => n + 1); return Promise.resolve(); } diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts index d89696be360..de5ac4a0747 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts @@ -1,5 +1,3 @@ -'use client'; - import { useRef } from 'react'; import type { PagesOrInfiniteOptions } from '../types'; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx index 1bee4557d16..d81d5bbbf76 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useSWR, useSWRInfinite } from '../clerk-swr'; import type { CacheSetter, ValueOrSetter } from '../types'; +import { toSWRQuery } from './createCacheKeys'; import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; import { getDifferentKeys, useWithSafeValues } from './usePagesOrInfinite.shared'; import { usePreviousValue } from './usePreviousValue'; @@ -49,9 +50,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { const isSignedIn = config.isSignedIn; const pagesCacheKey = { - type: keys.queryKey[0], - ...keys.queryKey[2], - ...keys.queryKey[3].args, + ...toSWRQuery(keys), initialPage: paginatedPage, pageSize: pageSizeRef.current, }; @@ -136,9 +135,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { } return { - type: keys.queryKey[0], - ...keys.queryKey[2], - ...keys.queryKey[3].args, + ...toSWRQuery(keys), initialPage: initialPageRef.current + pageIndex, pageSize: pageSizeRef.current, }; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx new file mode 100644 index 00000000000..4dfe06c1a40 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -0,0 +1,50 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +/** + * @internal + */ +function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { + const { paymentAttemptId, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = usePaymentAttemptQueryCacheKeys({ + paymentAttemptId, + userId, + orgId: organizationId, + for: forType, + }); + + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(paymentAttemptId) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: ({ queryKey }) => { + const args = queryKey[3].args; + return clerk.billing.getPaymentAttempt(args); + }, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + staleTime: 1_000 * 60, + }); + + return { + data: query.data, + error: (query.error ?? null) as PaymentAttemptQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} + +export { usePaymentAttemptQuery as __internal_usePaymentAttemptQuery }; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts new file mode 100644 index 00000000000..088b28855fc --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '../../types'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function usePaymentAttemptQueryCacheKeys(params: { + paymentAttemptId: string; + userId: string | null; + orgId: string | null; + for?: ForPayerType; +}) { + const { paymentAttemptId, userId, orgId, for: forType } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.PAYMENT_ATTEMPT_KEY, + authenticated: true, + tracked: { + paymentAttemptId, + forType, + userId, + orgId, + }, + untracked: { + args: { + id: paymentAttemptId ?? undefined, + orgId: orgId ?? undefined, + }, + }, + }); + }, [paymentAttemptId, forType, userId, orgId]); +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx new file mode 100644 index 00000000000..d47d5d52246 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx @@ -0,0 +1,48 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +/** + * This is the existing implementation of usePaymentAttemptQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { + const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = usePaymentAttemptQueryCacheKeys({ + paymentAttemptId, + userId, + orgId: organizationId, + for: forType, + }); + + const queryEnabled = Boolean(paymentAttemptId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + + const swr = useSWR( + queryEnabled ? { queryKey } : null, + ({ queryKey }) => { + const args = queryKey[3].args; + return clerk.billing.getPaymentAttempt(args); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as PaymentAttemptQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx new file mode 100644 index 00000000000..ffa7ea1dc6e --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx @@ -0,0 +1 @@ +export { __internal_usePaymentAttemptQuery } from 'virtual:data-hooks/usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts b/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts new file mode 100644 index 00000000000..5e8fb16ad92 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts @@ -0,0 +1,52 @@ +import type { ClerkAPIResponseError } from '../../error'; +import type { BillingPaymentResource, ForPayerType } from '../../types'; + +/** + * @interface + */ +export type UsePaymentAttemptQueryParams = { + /** + * The payment attempt ID to fetch. + */ + paymentAttemptId: string; + /** + * Specifies whether to fetch the payment attempt for an organization or a user. + * + * @default 'user' + */ + for?: ForPayerType; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default false + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type PaymentAttemptQueryResult = { + /** + * The payment attempt object, `undefined` before the first fetch, or `null` if no payment attempt exists. + */ + data: BillingPaymentResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx new file mode 100644 index 00000000000..c2a7ec96cbd --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -0,0 +1,45 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; + +/** + * @internal + */ +export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + const { planId, initialPlan = null, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + const targetPlanId = planId ?? initialPlan?.id ?? null; + + const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); + + const billingEnabled = useBillingHookEnabled({ + authenticated: false, + }); + + const queryEnabled = Boolean(targetPlanId) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!targetPlanId) { + throw new Error('planId is required to fetch plan details'); + } + return clerk.billing.getPlan({ id: targetPlanId }); + }, + enabled: queryEnabled, + initialData: initialPlan ?? undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + initialDataUpdatedAt: 0, + }); + + return { + data: query.data, + error: (query.error ?? null) as PlanDetailsQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts b/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts new file mode 100644 index 00000000000..9a83f3bba5d --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function usePlanDetailsQueryCacheKeys(params: { planId: string | null }) { + const { planId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.BILLING_PLANS_KEY, + authenticated: false, + tracked: { + planId: planId ?? null, + }, + untracked: { + args: { + id: planId ?? undefined, + }, + }, + }); + }, [planId]); +} diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx new file mode 100644 index 00000000000..ce544fce5b4 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -0,0 +1,45 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext } from '../contexts'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; + +/** + * This is the existing implementation of usePlanDetailsQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + const targetPlanId = planId ?? initialPlan?.id ?? null; + + const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); + + const queryEnabled = Boolean(targetPlanId) && enabled; + + const swr = useSWR( + queryEnabled ? queryKey : null, + () => { + if (!targetPlanId) { + throw new Error('planId is required to fetch plan details'); + } + return clerk.billing.getPlan({ id: targetPlanId }); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + fallbackData: initialPlan ?? undefined, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as PlanDetailsQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} + +export { usePlanDetailsQuery as __internal_usePlanDetailsQuery }; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx new file mode 100644 index 00000000000..7fb85951400 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx @@ -0,0 +1 @@ +export { __internal_usePlanDetailsQuery } from 'virtual:data-hooks/usePlanDetailsQuery'; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts b/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts new file mode 100644 index 00000000000..9dfae918964 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts @@ -0,0 +1,50 @@ +import type { ClerkAPIResponseError } from '../../errors/clerkApiResponseError'; +import type { BillingPlanResource } from '../../types'; + +/** + * @interface + */ +export type UsePlanDetailsQueryParams = { + /** + * The plan ID to fetch. + */ + planId?: string | null; + /** + * Initial plan data to use before fetching. + */ + initialPlan?: BillingPlanResource | null; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default true + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type PlanDetailsQueryResult = { + /** + * The plan object, `undefined` before the first fetch, or `null` if no plan exists. + */ + data: BillingPlanResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx new file mode 100644 index 00000000000..c0be99ef100 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -0,0 +1,52 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; + +/** + * @internal + */ +function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { + const { statementId = null, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = useStatementQueryCacheKeys({ + statementId, + userId, + orgId: organizationId, + for: forType, + }); + + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(statementId) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!statementId) { + throw new Error('statementId is required to fetch a statement'); + } + return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); + }, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + staleTime: 1_000 * 60, + }); + + return { + data: query.data, + error: (query.error ?? null) as StatementQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} + +export { useStatementQuery as __internal_useStatementQuery }; diff --git a/packages/shared/src/react/hooks/useStatementQuery.shared.ts b/packages/shared/src/react/hooks/useStatementQuery.shared.ts new file mode 100644 index 00000000000..1aa6d052261 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.shared.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '../../types'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function useStatementQueryCacheKeys(params: { + statementId: string | null; + userId: string | null; + orgId: string | null; + for?: ForPayerType; +}) { + const { statementId, userId, orgId, for: forType } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.BILLING_STATEMENTS_KEY, + authenticated: true, + tracked: { + statementId, + forType, + userId, + orgId, + }, + untracked: { + args: { + id: statementId ?? undefined, + orgId: orgId ?? undefined, + }, + }, + }); + }, [statementId, forType, userId, orgId]); +} diff --git a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx new file mode 100644 index 00000000000..8d209d75f66 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx @@ -0,0 +1,50 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; + +/** + * This is the existing implementation of useStatementQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function __internal_useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { + const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = useStatementQueryCacheKeys({ + statementId, + userId, + orgId: organizationId, + for: forType, + }); + + const queryEnabled = Boolean(statementId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + + const swr = useSWR( + queryEnabled ? queryKey : null, + () => { + if (!statementId) { + throw new Error('statementId is required to fetch a statement'); + } + return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as StatementQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/useStatementQuery.tsx b/packages/shared/src/react/hooks/useStatementQuery.tsx new file mode 100644 index 00000000000..0664eedaefa --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.tsx @@ -0,0 +1 @@ +export { __internal_useStatementQuery } from 'virtual:data-hooks/useStatementQuery'; diff --git a/packages/shared/src/react/hooks/useStatementQuery.types.ts b/packages/shared/src/react/hooks/useStatementQuery.types.ts new file mode 100644 index 00000000000..fe7f6174aec --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.types.ts @@ -0,0 +1,51 @@ +import type { BillingStatementResource, ClerkAPIResponseError, ForPayerType } from '../../types'; + +/** + * @interface + */ +export type UseStatementQueryParams = { + /** + * The statement ID to fetch. + */ + statementId?: string | null; + /** + * Specifies whether to fetch the statement for an organization or a user. + * + * @default 'user' + */ + for?: ForPayerType; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default false + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type StatementQueryResult = { + /** + * The statement object, `undefined` before the first fetch, or `null` if no statement exists. + */ + data: BillingStatementResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index 9efb114e394..1fe429e9ceb 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; -import type { EnvironmentResource } from '../../types'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; import { useClerkQuery } from '../clerk-rq/useQuery'; import { @@ -10,31 +10,12 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; const HOOK_NAME = 'useSubscription'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - -export const subscriptionQuery = , U extends Record>(params: { - trackedKeys: T; - untrackedKeys?: U; -}) => { - const stableKey = STABLE_KEYS.SUBSCRIPTION_KEY; - const { trackedKeys, untrackedKeys } = params; - return { - queryKey: [stableKey, trackedKeys, untrackedKeys] as const, - invalidationKey: [stableKey, trackedKeys] as const, - stableKey, - }; -}; - /** * This is the new implementation of useSubscription using React Query. * It is exported only if the package is build with the `CLERK_USE_RQ` environment variable set to `true`. @@ -48,39 +29,31 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes const user = useUserContext(); const { organization } = useOrganizationContext(); - // @ts-expect-error `__unstable__environment` is not typed - const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + const billingEnabled = useBillingHookEnabled(params); clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const isOrganization = params?.for === 'organization'; - const billingEnabled = isOrganization - ? environment?.commerceSettings.billing.organization.enabled - : environment?.commerceSettings.billing.user.enabled; const keepPreviousData = params?.keepPreviousData ?? false; const [queryClient] = useClerkQueryClient(); - const { queryKey, invalidationKey } = useMemo(() => { - return subscriptionQuery({ - trackedKeys: { - userId: user?.id, - args: { orgId: isOrganization ? organization?.id : undefined }, - }, - }); - }, [user?.id, isOrganization, organization?.id]); + const { queryKey, invalidationKey } = useSubscriptionCacheKeys({ + userId: user?.id, + orgId: organization?.id, + for: params?.for, + }); - const queriesEnabled = Boolean(user?.id && billingEnabled) && (params?.enabled ?? true); + const queriesEnabled = Boolean(user?.id && billingEnabled); const query = useClerkQuery({ queryKey, queryFn: ({ queryKey }) => { - const obj = queryKey[1] as { args: { orgId?: string } }; + const obj = queryKey[3]; return clerk.billing.getSubscription(obj.args); }, staleTime: 1_000 * 60, enabled: queriesEnabled, - placeholderData: keepPreviousData && queriesEnabled ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled), }); const revalidate = useCallback( diff --git a/packages/shared/src/react/hooks/useSubscription.shared.ts b/packages/shared/src/react/hooks/useSubscription.shared.ts new file mode 100644 index 00000000000..c5655ebdcfd --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.shared.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '../../types'; +import { STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function useSubscriptionCacheKeys(params: { + userId: string | undefined; + orgId: string | undefined; + for?: ForPayerType; +}) { + const { userId, orgId, for: forType } = params; + return useMemo(() => { + const isOrganization = forType === 'organization'; + + const safeOrgId = isOrganization ? orgId : undefined; + return createCacheKeys({ + stablePrefix: STABLE_KEYS.SUBSCRIPTION_KEY, + authenticated: true, + tracked: { + userId, + orgId: safeOrgId, + }, + untracked: { + args: { orgId: safeOrgId }, + }, + }); + }, [userId, orgId, forType]); +} diff --git a/packages/shared/src/react/hooks/useSubscription.swr.tsx b/packages/shared/src/react/hooks/useSubscription.swr.tsx index 5a08ddcfc37..c142800b5c0 100644 --- a/packages/shared/src/react/hooks/useSubscription.swr.tsx +++ b/packages/shared/src/react/hooks/useSubscription.swr.tsx @@ -9,7 +9,7 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; const hookName = 'useSubscription'; @@ -38,16 +38,18 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes : environment?.commerceSettings.billing.user.enabled; const isEnabled = (params?.enabled ?? true) && billingEnabled; + const { queryKey } = useSubscriptionCacheKeys({ + userId: user?.id, + orgId: organization?.id, + for: params?.for, + }); + const swr = useSWR( - isEnabled - ? { - type: STABLE_KEYS.SUBSCRIPTION_KEY, - userId: user?.id, - args: { orgId: isOrganization ? organization?.id : undefined }, - } - : null, - ({ args, userId }) => { - if (userId) { + isEnabled ? { queryKey } : null, + ({ queryKey }) => { + const args = queryKey[3].args; + + if (queryKey[2].userId) { return clerk.billing.getSubscription(args); } return null; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index c1f8f761236..b05c94db6bd 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -1,5 +1,7 @@ export * from './hooks'; +export type { UseSubscriptionParams } from './hooks/useSubscription.types'; + export { ClerkInstanceContext, ClientContext, @@ -17,4 +19,4 @@ export { __experimental_CheckoutProvider, } from './contexts'; -export * from './commerce'; +export * from './billing/payment-element'; diff --git a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx index 63bb029b3ac..40810747d89 100644 --- a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -1,10 +1,8 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; -import { SWRConfig } from 'swr'; - /** * @internal */ -export function SWRConfigCompat({ swrConfig, children }: PropsWithChildren<{ swrConfig?: any }>) { - return {children}; +export function SWRConfigCompat({ children }: PropsWithChildren<{ swrConfig?: any }>) { + return <>{children}; } diff --git a/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx index 555d744474b..97d341456d1 100644 --- a/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx +++ b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx @@ -5,5 +5,6 @@ import { SWRConfig } from 'swr'; * @internal */ export function SWRConfigCompat({ swrConfig, children }: PropsWithChildren<{ swrConfig?: any }>) { + // TODO: Replace SWRConfig with the react-query equivalent. return {children}; } diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index bf1a5e73b63..ef597662235 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -51,3 +51,19 @@ export const STABLE_KEYS = { } as const; export type ResourceCacheStableKey = (typeof STABLE_KEYS)[keyof typeof STABLE_KEYS]; + +/** + * Internal stable keys for queries only used by our UI components. + * These keys are not used by the hooks themselves. + */ + +const PAYMENT_ATTEMPT_KEY = 'billing-payment-attempt'; +const BILLING_PLANS_KEY = 'billing-plan'; +const BILLING_STATEMENTS_KEY = 'billing-statement'; +export const INTERNAL_STABLE_KEYS = { + PAYMENT_ATTEMPT_KEY, + BILLING_PLANS_KEY, + BILLING_STATEMENTS_KEY, +} as const; + +export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS]; diff --git a/packages/shared/src/types/virtual-data-hooks.d.ts b/packages/shared/src/types/virtual-data-hooks.d.ts index 141a75a01ce..680d0d56269 100644 --- a/packages/shared/src/types/virtual-data-hooks.d.ts +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -3,6 +3,10 @@ declare module 'virtual:data-hooks/*' { export const SWRConfigCompat: any; export const useSubscription: any; export const usePagesOrInfinite: any; + export const useAPIKeys: any; + export const __internal_useStatementQuery: any; + export const __internal_usePlanDetailsQuery: any; + export const __internal_usePaymentAttemptQuery: any; const mod: any; export default mod; } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 6a0663ee028..27300eb64f4 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -23,9 +23,16 @@ "declarationMap": true, "allowJs": true, "paths": { + "virtual:data-hooks/useAPIKeys": ["./src/react/hooks/useAPIKeys.swr.tsx"], "virtual:data-hooks/useSubscription": ["./src/react/hooks/useSubscription.swr.tsx"], "virtual:data-hooks/SWRConfigCompat": ["./src/react/providers/SWRConfigCompat.swr.tsx"], - "virtual:data-hooks/usePagesOrInfinite": ["./src/react/hooks/usePagesOrInfinite.swr.tsx"] + "virtual:data-hooks/usePagesOrInfinite": ["./src/react/hooks/usePagesOrInfinite.swr.tsx"], + "virtual:data-hooks/useStatementQuery": ["./src/react/hooks/useStatementQuery.swr.tsx"], + "virtual:data-hooks/usePaymentAttemptQuery": ["./src/react/hooks/usePaymentAttemptQuery.swr.tsx"], + "virtual:data-hooks/usePlanDetailsQuery": ["./src/react/hooks/usePlanDetailsQuery.swr.tsx"], + "virtual:data-hooks/useInitializePaymentMethod": ["./src/react/billing/useInitializePaymentMethod.swr.tsx"], + "virtual:data-hooks/useStripeClerkLibs": ["./src/react/billing/useStripeClerkLibs.swr.tsx"], + "virtual:data-hooks/useStripeLoader": ["./src/react/billing/useStripeLoader.swr.tsx"] } }, "exclude": ["node_modules"], diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 499714fd139..1d0104ac052 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -64,9 +64,11 @@ const HookAliasPlugin = () => { const chosenRQ = rqHooks.has(name) || useRQ; const impl = chosenRQ ? `${name}.rq.tsx` : `${name}.swr.tsx`; - const candidates = name.toLowerCase().includes('provider') - ? [path.join(baseDir, 'src', 'react', 'providers', impl), path.join(baseDir, 'src', 'react', 'hooks', impl)] - : [path.join(baseDir, 'src', 'react', 'hooks', impl), path.join(baseDir, 'src', 'react', 'providers', impl)]; + const candidates = [ + path.join(baseDir, 'src', 'react', 'hooks', impl), + path.join(baseDir, 'src', 'react', 'billing', impl), + path.join(baseDir, 'src', 'react', 'providers', impl), + ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { diff --git a/packages/shared/vitest.config.mts b/packages/shared/vitest.config.mts index abfa8ee1250..0e79115c153 100644 --- a/packages/shared/vitest.config.mts +++ b/packages/shared/vitest.config.mts @@ -21,17 +21,11 @@ function HookAliasPlugin() { const candidates: string[] = []; for (const base of baseDirs) { - if (name.toLowerCase().includes('provider')) { - candidates.push( - path.join(base, 'src', 'react', 'providers', impl), - path.join(base, 'src', 'react', 'hooks', impl), - ); - } else { - candidates.push( - path.join(base, 'src', 'react', 'hooks', impl), - path.join(base, 'src', 'react', 'providers', impl), - ); - } + candidates.push( + path.join(base, 'src', 'react', 'hooks', impl), + path.join(base, 'src', 'react', 'billing', impl), + path.join(base, 'src', 'react', 'providers', impl), + ); } for (const candidate of candidates) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f65b6e000b..b4e64573239 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -500,9 +500,6 @@ importers: regenerator-runtime: specifier: 0.14.1 version: 0.14.1 - swr: - specifier: 2.3.4 - version: 2.3.4(react@18.3.1) devDependencies: '@clerk/testing': specifier: workspace:^ @@ -2599,7 +2596,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==}