Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
};
const card = useCardState();
const clerk = useClerk();
// TODO: Replace useSWRMutation with the react-query equivalent.
const {
data: createdAPIKey,
trigger: createAPIKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,28 +30,16 @@ 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 ?? null,
for: requesterType,
});

const subscriptionItem = paymentAttempt?.subscriptionItem;

Expand Down
24 changes: 9 additions & 15 deletions packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useClerk } from '@clerk/shared/react';
import { __internal_usePlanDetailsQuery } from '@clerk/shared/react/index';
import type {
__internal_PlanDetailsProps,
BillingPlanResource,
Expand All @@ -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';
Expand All @@ -29,6 +28,8 @@ import {
useLocalizations,
} from '../../customizables';

type PlanFeature = BillingPlanResource['features'][number];

export const PlanDetails = (props: __internal_PlanDetailsProps) => {
return (
<Flow.Root flow='planDetails'>
Expand Down Expand Up @@ -79,24 +80,17 @@ const PlanDetailsInternal = ({
plan: initialPlan,
initialPlanPeriod = 'month',
}: __internal_PlanDetailsProps) => {
const clerk = useClerk();
const [planPeriod, setPlanPeriod] = useState<BillingSubscriptionPlanPeriod>(initialPlanPeriod);

const {
data: plan,
isLoading,
error,
} = useSWR<BillingPlanResource, ClerkAPIResponseError>(
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 (
Expand Down Expand Up @@ -161,7 +155,7 @@ const PlanDetailsInternal = ({
margin: 0,
})}
>
{features.map(feature => (
{features.map((feature: PlanFeature) => (
<Box
key={feature.id}
elementDescriptor={descriptors.planDetailFeaturesListItem}
Expand Down
33 changes: 12 additions & 21 deletions packages/clerk-js/src/ui/components/Statements/StatementPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useClerk, useOrganizationContext } from '@clerk/shared/react';
import useSWR from 'swr';
import { __internal_useStatementQuery } from '@clerk/shared/react/index';
import type { BillingStatementResource } from '@clerk/shared/types';

import { Alert } from '@/ui/elements/Alert';
import { Header } from '@/ui/elements/Header';
Expand All @@ -20,33 +20,24 @@ import { ArrowRightIcon, Plus, RotateLeftRight } from '../../icons';
import { useRouter } from '../../router';
import { Statement } from './Statement';

type StatementGroup = BillingStatementResource['groups'][number];
type StatementItem = StatementGroup['items'][number];

export const StatementPage = () => {
const { params, navigate } = useRouter();
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 ?? null,
for: requesterType,
});

if (isLoading) {
return (
Expand Down Expand Up @@ -99,11 +90,11 @@ export const StatementPage = () => {
status={statement.status}
/>
<Statement.Body>
{statement.groups.map(group => (
{statement.groups.map((group: StatementGroup) => (
<Statement.Section key={group.timestamp.toISOString()}>
<Statement.SectionHeader text={formatDate(group.timestamp, 'long')} />
<Statement.SectionContent>
{group.items.map(item => (
{group.items.map((item: StatementItem) => (
<Statement.SectionContentItem key={item.id}>
<Statement.SectionContentDetailsHeader
title={item.subscriptionItem.plan.name}
Expand Down
Empty file.
Empty file.
Empty file.
1 change: 1 addition & 0 deletions packages/shared/src/react/clerk-swr.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/react/commerce.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const [StripeLibsContext, useStripeLibsContext] = createContextAndHook<{

const StripeLibsProvider = ({ children }: PropsWithChildren) => {
const clerk = useClerk();
// TODO: Replace useSWR with the react-query equivalent.
const { data: stripeClerkLibs } = useSWR(
'clerk-stripe-sdk',
async () => {
Expand Down Expand Up @@ -85,6 +86,7 @@ const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => {
const resource = forResource === 'organization' ? organization : user;
const stripeClerkLibs = useStripeLibsContext();

// TODO: Replace useSWRMutation with the react-query equivalent.
const { data: initializedPaymentMethod, trigger: initializePaymentMethod } = useSWRMutation(
{
key: 'billing-payment-method-initialize',
Expand Down Expand Up @@ -113,6 +115,7 @@ const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => {
const paymentMethodOrder = initializedPaymentMethod?.paymentMethodOrder;
const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey;

// TODO: Replace useSWR with the react-query equivalent.
const { data: stripe } = useSWR(
stripeClerkLibs && externalGatewayId && stripePublishableKey
? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey }
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/react/hooks/__tests__/wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { SWRConfig } from 'swr';

export const wrapper = ({ children }: { children: React.ReactNode }) => (
// TODO: Replace SWRConfig with the react-query equivalent.
<SWRConfig
value={{
provider: () => new Map(),
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/react/hooks/createCacheKeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ResourceCacheStableKey } from '../stable-keys';
import type { __internal_ResourceCacheStableKey, ResourceCacheStableKey } from '../stable-keys';
import type { QueryKeyWithArgs } from './usePageOrInfinite.types';

/**
* @internal
Expand All @@ -8,7 +9,7 @@ export function createCacheKeys<
T extends Record<string, unknown> = Record<string, unknown>,
U extends Record<string, unknown> | undefined = undefined,
>(params: {
stablePrefix: ResourceCacheStableKey;
stablePrefix: ResourceCacheStableKey | __internal_ResourceCacheStableKey;
authenticated: boolean;
tracked: T;
untracked: U extends { args: Params } ? U : never;
Expand All @@ -20,3 +21,12 @@ export function createCacheKeys<
authenticated: params.authenticated,
};
}

export function toSWRQuery<T extends { queryKey: QueryKeyWithArgs<unknown> }>(keys: T) {
const { queryKey } = keys;
return {
type: queryKey[0],
...queryKey[2],
...(queryKey[3] as { args: Record<string, unknown> }).args,
};
}
3 changes: 3 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ 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';
export { __internal_useStatementQuery } from './useStatementQuery';
export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery';
export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery';
4 changes: 3 additions & 1 deletion packages/shared/src/react/hooks/usePageOrInfinite.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type QueryArgs<Params> = Readonly<{
args: Params;
}>;

type QueryKeyWithArgs<Params> = readonly [
export type QueryKeyWithArgs<Params> = readonly [
string,
boolean,
Record<string, unknown>,
Expand All @@ -27,6 +27,8 @@ export type UsePagesOrInfiniteSignature = <
queryKey: QueryKeyWithArgs<Params>;
invalidationKey: InvalidationQueryKey;
stableKey: string;
authenticated: boolean;
// toSWRQuery: () => Record<string, unknown>;
},
TConfig extends Config = Config,
>(params: {
Expand Down
9 changes: 3 additions & 6 deletions packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down
66 changes: 66 additions & 0 deletions packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { eventMethodCalled } from '../../telemetry/events';
import { useClerkQuery } from '../clerk-rq/useQuery';
import {
useAssertWrappedByClerkProvider,
useClerkInstanceContext,
useOrganizationContext,
useUserContext,
} from '../contexts';
import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared';
import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types';

const HOOK_NAME = 'usePaymentAttemptQuery';

/**
* @internal
*/
function KeepPreviousDataFn<Data>(previousData: Data): Data {
return previousData;
}

/**
* This is the new implementation of usePaymentAttemptQuery using React Query.
* It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`.
*
* @internal
*/
export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult {
useAssertWrappedByClerkProvider(HOOK_NAME);

const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params;
const clerk = useClerkInstanceContext();
const user = useUserContext();
const { organization } = useOrganizationContext();

clerk.telemetry?.record(eventMethodCalled(HOOK_NAME));

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 query = useClerkQuery({
queryKey,
queryFn: ({ queryKey }) => {
const args = queryKey[3].args;
return clerk.billing.getPaymentAttempt(args);
},
enabled: queryEnabled,
placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined,
staleTime: 1_000 * 60,
});

return {
data: query.data,
error: (query.error ?? null) as PaymentAttemptQueryResult['error'],
isLoading: query.isLoading,
isFetching: query.isFetching,
};
Comment on lines +42 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Verify error type compatibility.

Line 44 uses a type assertion to coerce query.error to PaymentAttemptQueryResult['error'] (which is ClerkAPIResponseError | null). This bypasses TypeScript's type checking and could hide type mismatches if the underlying query error type doesn't match ClerkAPIResponseError.

Please verify that errors thrown by clerk.billing.getPaymentAttempt are actually of type ClerkAPIResponseError and that the error handling in the query library preserves this type. Consider removing the type assertion if types are compatible, or add runtime validation if they might differ.


🏁 Script executed:

#!/bin/bash
# Description: Search for the getPaymentAttempt implementation and error handling to verify error types

# Search for getPaymentAttempt method definition
ast-grep --pattern 'getPaymentAttempt($$$) {
  $$$
}'

# Search for ClerkAPIResponseError usage in billing-related code
rg -n --type=ts -C3 'ClerkAPIResponseError' -g '*billing*'

# Search for error handling in clerk-rq useQuery implementation
rg -n --type=ts -C5 'export function useClerkQuery' -A 20

Length of output: 2693


🏁 Script executed:

#!/bin/bash
# Search for the full usePaymentAttemptQuery implementation
rg -n --type=ts -B5 -A30 'function usePaymentAttemptQuery' packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx

# Search for PaymentAttemptQueryResult type definition
rg -n --type=ts 'type PaymentAttemptQueryResult\|interface PaymentAttemptQueryResult'

# Search for clerk.billing.getPaymentAttempt usage and type hints
rg -n --type=ts 'clerk\.billing\.getPaymentAttempt\|getPaymentAttempt' -g '*billing*'

Length of output: 1248


🏁 Script executed:

#!/bin/bash
# View the full file to see the complete return statement
cat packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx

# Search for .types file with PaymentAttemptQueryResult
fd 'usePaymentAttemptQuery.types' packages/

Length of output: 1831


🏁 Script executed:

#!/bin/bash
# View the types file
cat packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts

# Search for DefaultError definition
rg -n --type=ts 'type DefaultError\|export.*DefaultError' packages/shared/src/react/

Length of output: 1329


🏁 Script executed:

#!/bin/bash
# Search for DefaultError from React Query
rg -n 'DefaultError' packages/shared/src/react/clerk-rq/ -A2 -B2

# Search for other uses of useClerkQuery with explicit TError
rg -n 'useClerkQuery' packages/shared/src/react/hooks/ -A5 | head -100

# Check if clerk.billing methods are documented to throw ClerkAPIResponseError
rg -n 'clerk\.billing\.' packages/shared/src/react/hooks/ -C2

Length of output: 24853


🏁 Script executed:

#!/bin/bash
# Check return statements in similar query hooks for type assertions
rg -n 'error:' packages/shared/src/react/hooks/ --type=ts -A2 -B2 | grep -A5 -B5 'as'

# Search for all type assertions on error in billing hooks
rg -n 'as.*\[.*error' packages/shared/src/react/hooks/ --type=ts

# View useSubscription return to compare patterns
cat packages/shared/src/react/hooks/useSubscription.rq.tsx | tail -20

# View usePlanDetailsQuery return to compare patterns  
cat packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx | tail -20

Length of output: 6438


Add explicit TError parameter to useClerkQuery or validate error types at runtime.

The type assertion on line 44 masks a real type mismatch. useClerkQuery defaults to TError = DefaultError (from @tanstack/query-core), but the function returns error: ClerkAPIResponseError | null. This same pattern affects useStatementQuery and usePlanDetailsQuery.

Fix by either:

  1. Passing explicit TError parameter: useClerkQuery<..., ClerkAPIResponseError>({...})
  2. Or adding runtime validation to ensure clerk.billing.getPaymentAttempt errors are ClerkAPIResponseError

Similar hooks like useSubscription.rq.tsx handle this differently (converting to undefined without type assertion), suggesting inconsistent error handling across the codebase.

🤖 Prompt for AI Agents
In packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx around lines 42
to 47, the return casts query.error to PaymentAttemptQueryResult['error']
masking a type mismatch; change the useClerkQuery invocation to supply the
explicit TError generic (e.g., ClerkAPIResponseError) so the query.error type
matches the hook's declared error type, remove the runtime type assertion on
return, and apply the same explicit-generic fix to useStatementQuery and
usePlanDetailsQuery (alternatively add runtime validation that converts
non-ClerkAPIResponseError values to null/undefined before returning).

}
32 changes: 32 additions & 0 deletions packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading
Loading