diff --git a/.changeset/great-bees-teach.md b/.changeset/great-bees-teach.md new file mode 100644 index 00000000000..e0416410717 --- /dev/null +++ b/.changeset/great-bees-teach.md @@ -0,0 +1,5 @@ +--- +'@clerk/chrome-extension': patch +--- + +Refactor re-exports from `@clerk/clerk-react`. diff --git a/.changeset/happy-kiwis-peel.md b/.changeset/happy-kiwis-peel.md new file mode 100644 index 00000000000..e4d1678a80a --- /dev/null +++ b/.changeset/happy-kiwis-peel.md @@ -0,0 +1,10 @@ +--- +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/shared': minor +--- + +Export experimental hooks and components for PaymentElement +- `__experimental_usePaymentElement` +- `__experimental_PaymentElementProvider` +- `__experimental_PaymentElement` diff --git a/.changeset/hot-seas-rhyme.md b/.changeset/hot-seas-rhyme.md new file mode 100644 index 00000000000..e4d2db2d9fa --- /dev/null +++ b/.changeset/hot-seas-rhyme.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Remove `@stripe/react-stripe-js` dependency and only allow loading of stripe-js via `Clerk.__internal_loadStripeJs()`. diff --git a/.changeset/many-mirrors-vanish.md b/.changeset/many-mirrors-vanish.md new file mode 100644 index 00000000000..10e57da157e --- /dev/null +++ b/.changeset/many-mirrors-vanish.md @@ -0,0 +1,10 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/remix': minor +--- + +Export experimental hooks and components for PaymentElement +- `__experimental_usePaymentElement` +- `__experimental_PaymentElementProvider` +- `__experimental_PaymentElement` diff --git a/.changeset/olive-streets-fall.md b/.changeset/olive-streets-fall.md new file mode 100644 index 00000000000..27d6a78a6f2 --- /dev/null +++ b/.changeset/olive-streets-fall.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': minor +--- + +Add `__internal_loadStripeJs` in Clerk interface. diff --git a/packages/chrome-extension/src/index.ts b/packages/chrome-extension/src/index.ts index 931e8970374..ec4cc5aa03e 100644 --- a/packages/chrome-extension/src/index.ts +++ b/packages/chrome-extension/src/index.ts @@ -1,4 +1,4 @@ -export * from '@clerk/clerk-react'; +export * from './react/re-exports'; export type { StorageCache } from './internal/utils/storage'; diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts new file mode 100644 index 00000000000..a6f82ee02c2 --- /dev/null +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -0,0 +1,41 @@ +export { + APIKeys, + AuthenticateWithRedirectCallback, + ClerkDegraded, + ClerkFailed, + ClerkLoaded, + ClerkLoading, + CreateOrganization, + OrganizationList, + OrganizationProfile, + OrganizationSwitcher, + PricingTable, + Protect, + RedirectToCreateOrganization, + RedirectToOrganizationProfile, + RedirectToSignIn, + RedirectToSignUp, + RedirectToUserProfile, + SignIn, + SignInButton, + SignInWithMetamaskButton, + SignOutButton, + SignUp, + SignUpButton, + SignedIn, + SignedOut, + UserButton, + UserProfile, + Waitlist, + useAuth, + useClerk, + useEmailLink, + useOrganization, + useOrganizationList, + useReverification, + useSession, + useSessionList, + useSignIn, + useSignUp, + useUser, +} from '@clerk/clerk-react'; diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f58f41096dd..36cca6884b4 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,9 +4,10 @@ { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "111.7KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, + { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, { "path": "./dist/impersonationfab*.js", "maxSize": "5KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "12KB" }, @@ -21,8 +22,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "7.3KB" }, - { "path": "./dist/paymentSources*.js", "maxSize": "9.17KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.34KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/sessionTasks*.js", "maxSize": "1.5KB" } diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 3543bbd446e..1ec82ab3dd5 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -69,7 +69,6 @@ "@floating-ui/react": "0.27.12", "@floating-ui/react-dom": "^2.1.3", "@formkit/auto-animate": "^0.8.2", - "@stripe/react-stripe-js": "3.1.1", "@stripe/stripe-js": "5.6.0", "@swc/helpers": "^0.5.17", "@zxcvbn-ts/core": "3.0.4", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 912bc51cc46..3c1aa80a6a9 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -81,6 +81,7 @@ const common = ({ mode, variant, disableRHC = false }) => { * Necessary to prevent the Stripe dependencies from being bundled into * SDKs such as Browser Extensions. */ + // TODO: @COMMERCE: Do we still need this? externals: disableRHC ? ['@stripe/stripe-js', '@stripe/react-stripe-js'] : undefined, optimization: { splitChunks: { @@ -100,6 +101,12 @@ const common = ({ mode, variant, disableRHC = false }) => { name: 'coinbase-wallet-sdk', chunks: 'all', }, + stripeVendor: { + test: /[\\/]node_modules[\\/](@stripe\/stripe-js)[\\/]/, + name: 'stripe-vendors', + chunks: 'all', + enforce: true, + }, /** * Sign up is shared between the SignUp component and the SignIn component. */ @@ -108,17 +115,6 @@ const common = ({ mode, variant, disableRHC = false }) => { name: 'signup', test: module => !!(module.resource && module.resource.includes('/ui/components/SignUp')), }, - paymentSources: { - minChunks: 1, - name: 'paymentSources', - test: module => - !!( - module.resource && - (module.resource.includes('/ui/components/PaymentSources') || - // Include `@stripe/react-stripe-js` and `@stripe/stripe-js` in the checkout chunk - module.resource.includes('/node_modules/@stripe')) - ), - }, common: { minChunks: 1, name: 'ui-common', diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7e27858023f..5374806db9c 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -645,6 +645,16 @@ export class Clerk implements ClerkInterface { .then(controls => controls.closeModal('blankCaptcha')); }; + public __internal_loadStripeJs = async () => { + if (__BUILD_DISABLE_RHC__) { + clerkUnsupportedEnvironmentWarning('Stripe'); + return { loadStripe: () => Promise.resolve(null) }; + } + + const { loadStripe } = await import('@stripe/stripe-js'); + return loadStripe; + }; + public openSignUp = (props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 0c8f5912333..5e605dcb55c 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -5,7 +5,6 @@ import type { CommercePaymentSourceResource, ConfirmCheckoutParams, } from '@clerk/types'; -import type { SetupIntent } from '@stripe/stripe-js'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -146,25 +145,18 @@ const useCheckoutMutations = () => { } }; - const payWithExistingPaymentSource = async (e: React.FormEvent) => { + const payWithExistingPaymentSource = (e: React.FormEvent) => { e.preventDefault(); const data = new FormData(e.currentTarget); const paymentSourceId = data.get('payment_source_id') as string; - await confirmCheckout({ + return confirmCheckout({ paymentSourceId, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; - const addPaymentSourceAndPay = async (ctx: { stripeSetupIntent?: SetupIntent }) => { - await confirmCheckout({ - gateway: 'stripe', - paymentToken: ctx.stripeSetupIntent?.payment_method as string, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - }); - }; + const addPaymentSourceAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx); const payWithTestCard = async () => { card.setLoading(); diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index d3168940d15..5ce088e1170 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -1,12 +1,12 @@ -import { createContextAndHook, useOrganization, useUser } from '@clerk/shared/react'; +import { + __experimental_PaymentElement as PaymentElement, + __experimental_PaymentElementProvider as PaymentElementProvider, + __experimental_usePaymentElement as usePaymentElement, + createContextAndHook, +} from '@clerk/shared/react'; import type { CommerceCheckoutResource } from '@clerk/types'; -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import type { Appearance as StripeAppearance, SetupIntent } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; import type { PropsWithChildren } from 'react'; -import { useEffect, useRef, useState } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; import { useCardState } from '@/ui/elements/contexts'; @@ -16,77 +16,61 @@ import { FormContainer } from '@/ui/elements/FormContainer'; import { handleError } from '@/ui/utils/errorHandler'; import { normalizeColorString } from '@/ui/utils/normalizeColorString'; -import { clerkUnsupportedEnvironmentWarning } from '../../../core/errors'; -import { useEnvironment, useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; +import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables'; import type { LocalizationKey } from '../../localization'; +const useStripeAppearance = () => { + const theme = useAppearance().parsedInternalTheme; + + return useMemo(() => { + const { colors, fontWeights, fontSizes, radii, space } = theme; + return { + colorPrimary: normalizeColorString(colors.$primary500), + colorBackground: normalizeColorString(colors.$colorInputBackground), + colorText: normalizeColorString(colors.$colorText), + colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), + colorSuccess: normalizeColorString(colors.$success500), + colorDanger: normalizeColorString(colors.$danger500), + colorWarning: normalizeColorString(colors.$warning500), + fontWeightNormal: fontWeights.$normal.toString(), + fontWeightMedium: fontWeights.$medium.toString(), + fontWeightBold: fontWeights.$bold.toString(), + fontSizeXl: fontSizes.$xl, + fontSizeLg: fontSizes.$lg, + fontSizeSm: fontSizes.$md, + fontSizeXs: fontSizes.$sm, + borderRadius: radii.$md, + spacingUnit: space.$1, + }; + }, [theme]); +}; + type AddPaymentSourceProps = { - onSuccess: (context: { stripeSetupIntent?: SetupIntent }) => Promise; + onSuccess: (context: { gateway: 'stripe'; paymentToken: string }) => Promise; checkout?: CommerceCheckoutResource; cancelAction?: () => void; }; -const usePaymentSourceUtils = () => { - const { organization } = useOrganization(); - const { user } = useUser(); - const subscriberType = useSubscriberTypeContext(); - const resource = subscriberType === 'org' ? organization : user; - - const { data: initializedPaymentSource, trigger: initializePaymentSource } = useSWRMutation( - { - key: 'commerce-payment-source-initialize', - resourceId: resource?.id, - }, - () => - resource?.initializePaymentSource({ - gateway: 'stripe', - }), - ); - const { commerceSettings } = useEnvironment(); - - const externalGatewayId = initializedPaymentSource?.externalGatewayId; - const externalClientSecret = initializedPaymentSource?.externalClientSecret; - const paymentMethodOrder = initializedPaymentSource?.paymentMethodOrder; - const stripePublishableKey = commerceSettings.billing.stripePublishableKey; - - const { data: stripe } = useSWR( - externalGatewayId && stripePublishableKey ? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey } : null, - ({ stripePublishableKey, externalGatewayId }) => { - if (__BUILD_DISABLE_RHC__) { - clerkUnsupportedEnvironmentWarning('Stripe'); - return; - } - return loadStripe(stripePublishableKey, { - stripeAccount: externalGatewayId, - }); - }, - { - keepPreviousData: true, - revalidateOnFocus: false, - dedupingInterval: 1_000 * 60, // 1 minute - }, - ); - - return { - stripe, - initializePaymentSource, - externalClientSecret, - paymentMethodOrder, - }; -}; - -const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHook('AddPaymentSourceRoot'); +const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHook< + AddPaymentSourceProps & { + headerTitle: LocalizationKey | undefined; + headerSubtitle: LocalizationKey | undefined; + submitLabel: LocalizationKey | undefined; + setHeaderTitle: (title: LocalizationKey) => void; + setHeaderSubtitle: (subtitle: LocalizationKey) => void; + setSubmitLabel: (label: LocalizationKey) => void; + onSuccess: (context: { gateway: 'stripe'; paymentToken: string }) => Promise; + } +>('AddPaymentSourceRoot'); -const AddPaymentSourceRoot = ({ children, ...rest }: PropsWithChildren) => { - const { initializePaymentSource, externalClientSecret, stripe, paymentMethodOrder } = usePaymentSourceUtils(); +const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren) => { + const subscriberType = useSubscriberTypeContext(); + const { t } = useLocalizations(); const [headerTitle, setHeaderTitle] = useState(undefined); const [headerSubtitle, setHeaderSubtitle] = useState(undefined); const [submitLabel, setSubmitLabel] = useState(undefined); - - useEffect(() => { - void initializePaymentSource(); - }, []); + const stripeAppearance = useStripeAppearance(); return ( - {children} + + {children} + ); }; const AddPaymentSourceLoading = (props: PropsWithChildren) => { - const { stripe, externalClientSecret } = useAddPaymentSourceContext(); + const { isProviderReady } = usePaymentElement(); - if (!stripe || !externalClientSecret) { + if (!isProviderReady) { return props.children; } @@ -122,44 +116,13 @@ const AddPaymentSourceLoading = (props: PropsWithChildren) => { }; const AddPaymentSourceReady = (props: PropsWithChildren) => { - const { externalClientSecret, stripe } = useAddPaymentSourceContext(); + const { isProviderReady } = usePaymentElement(); - const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; - const elementsAppearance: StripeAppearance = { - variables: { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, - }, - }; - - if (!stripe || !externalClientSecret) { + if (!isProviderReady) { return null; } - return ( - - {props.children} - - ); + return <>{props.children}; }; const Root = (props: PropsWithChildren) => { @@ -221,51 +184,28 @@ const FormButton = ({ text }: { text: LocalizationKey }) => { }; const AddPaymentSourceForm = ({ children }: PropsWithChildren) => { - const { - headerTitle, - headerSubtitle, - submitLabel, - checkout, - initializePaymentSource, - onSuccess, - cancelAction, - paymentMethodOrder, - } = useAddPaymentSourceContext(); - const [isPaymentElementReady, setIsPaymentElementReady] = useState(false); - const stripe = useStripe(); + const { headerTitle, headerSubtitle, submitLabel, checkout, onSuccess, cancelAction } = useAddPaymentSourceContext(); const card = useCardState(); - const elements = useElements(); - const { displayConfig } = useEnvironment(); - const { t } = useLocalizations(); const localizationRoot = useSubscriberTypeLocalizationRoot(); + const { isFormReady, submit: submitPaymentElement, reset } = usePaymentElement(); + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!stripe || !elements) { - return; - } - card.setLoading(); card.setError(undefined); - const { setupIntent, error } = await stripe.confirmSetup({ - elements, - confirmParams: { - return_url: '', // TODO(@COMMERCE): need to figure this out - }, - redirect: 'if_required', - }); + const { data, error } = await submitPaymentElement(); if (error) { return; // just return, since stripe will handle the error } - try { - await onSuccess({ stripeSetupIntent: setupIntent }); + await onSuccess(data); } catch (error) { void handleError(error, [], card.setError); } finally { card.setIdle(); - initializePaymentSource(); // resets the payment intent + void reset(); // resets the payment intent } }; @@ -283,32 +223,10 @@ const AddPaymentSourceForm = ({ children }: PropsWithChildren) => { })} > {children} - setIsPaymentElementReady(true)} - options={{ - layout: { - type: 'tabs', - defaultCollapsed: false, - }, - paymentMethodOrder, - applePay: checkout - ? { - recurringPaymentRequest: { - paymentDescription: `${t(localizationKeys(checkout.planPeriod === 'month' ? 'commerce.paymentSource.applePayDescription.monthly' : 'commerce.paymentSource.applePayDescription.annual'))}`, - managementURL: displayConfig.homeUrl, // TODO(@COMMERCE): is this the right URL? - regularBilling: { - amount: checkout.totals.totalDueNow?.amount || checkout.totals.grandTotal.amount, - label: checkout.plan.name, - recurringPaymentIntervalUnit: checkout.planPeriod === 'annual' ? 'year' : 'month', - }, - }, - } - : undefined, - }} - /> + {card.error} void const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const onAddPaymentSourceSuccess = async (context: { stripeSetupIntent?: SetupIntent }) => { + const onAddPaymentSourceSuccess = async (context: { gateway: 'stripe'; paymentToken: string }) => { const resource = subscriberType === 'org' ? clerk?.organization : clerk.user; - await resource?.addPaymentSource({ - gateway: 'stripe', - paymentToken: context.stripeSetupIntent?.payment_method as string, - }); + await resource?.addPaymentSource(context); onSuccess(); close(); return Promise.resolve(); diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index c7748535767..f3ba00b0012 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,9 @@ export { useSignUp, useUser, useReverification, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..194dc916d54 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -54,6 +54,9 @@ export { useSignUp, useUser, useReverification, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, } from './client-boundary/hooks'; /** diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 3a757e9ec5d..d257ba26175 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,9 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5a6cb60cb6b..bb52d3e0f93 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,4 +10,7 @@ export { useUser, useSession, useReverification, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, } from '@clerk/shared/react'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..52dcb289b10 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1310,6 +1310,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return clerkjs.authenticateWithGoogleOneTap(params); }; + __internal_loadStripeJs = async () => { + const clerkjs = await this.#waitForClerkJS(); + return clerkjs.__internal_loadStripeJs(); + }; + createOrganization = async (params: CreateOrganizationParams): Promise => { const callback = () => this.clerkjs?.createOrganization(params); if (this.clerkjs && this.loaded) { diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index e2ae044851f..98821467884 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,6 +34,9 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserProfile", "Waitlist", "WithClerkState", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/shared/package.json b/packages/shared/package.json index b00f8d810a8..0fd4bcb006d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -150,6 +150,8 @@ "swr": "^2.3.3" }, "devDependencies": { + "@stripe/react-stripe-js": "3.1.1", + "@stripe/stripe-js": "5.6.0", "@types/glob-to-regexp": "0.4.4", "@types/js-cookie": "3.0.6", "cross-fetch": "^4.0.0", diff --git a/packages/shared/src/getEnvVariable.ts b/packages/shared/src/getEnvVariable.ts index 24cd5d5e3fe..795a016f902 100644 --- a/packages/shared/src/getEnvVariable.ts +++ b/packages/shared/src/getEnvVariable.ts @@ -10,9 +10,10 @@ const hasCloudflareContext = (context: any): context is CloudflareEnv => { /** * Retrieves an environment variable across runtime environments. - * @param name - The environment variable name to retrieve - * @param context - Optional context object that may contain environment values - * @returns The environment variable value or empty string if not found + * + * @param name - The environment variable name to retrieve. + * @param context - Optional context object that may contain environment values. + * @returns The environment variable value or empty string if not found. */ export const getEnvVariable = (name: string, context?: Record): string => { // Node envs diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx new file mode 100644 index 00000000000..713e50b4488 --- /dev/null +++ b/packages/shared/src/react/commerce.tsx @@ -0,0 +1,301 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import type { CommerceCheckoutResource, EnvironmentResource } from '@clerk/types'; +import type { Stripe, StripeElements } from '@stripe/stripe-js'; +import { type PropsWithChildren, ReactNode, useCallback, useEffect, useState } from 'react'; +import React from 'react'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; + +import { createContextAndHook } from './hooks/createContextAndHook'; +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; + +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 + return clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; +}; + +const usePaymentSourceUtils = (forResource: 'org' | 'user') => { + const { organization } = useOrganization(); + const { user } = useUser(); + const resource = forResource === 'org' ? organization : user; + const stripeClerkLibs = useStripeLibsContext(); + + const { data: initializedPaymentSource, trigger: initializePaymentSource } = useSWRMutation( + { + key: 'commerce-payment-source-initialize', + resourceId: resource?.id, + }, + () => + resource?.initializePaymentSource({ + gateway: 'stripe', + }), + ); + const environment = useInternalEnvironment(); + + useEffect(() => { + initializePaymentSource().catch(() => { + // ignore errors + }); + }, []); + + const externalGatewayId = initializedPaymentSource?.externalGatewayId; + const externalClientSecret = initializedPaymentSource?.externalClientSecret; + const paymentMethodOrder = initializedPaymentSource?.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, + initializePaymentSource, + externalClientSecret, + paymentMethodOrder, + }; +}; + +type internalStripeAppearance = { + colorPrimary: string; + colorBackground: string; + colorText: string; + colorTextSecondary: string; + colorSuccess: string; + colorDanger: string; + colorWarning: string; + fontWeightNormal: string; + fontWeightMedium: string; + fontWeightBold: string; + fontSizeXl: string; + fontSizeLg: string; + fontSizeSm: string; + fontSizeXs: string; + borderRadius: string; + spacingUnit: string; +}; + +const [PaymentElementContext, usePaymentElementContext] = createContextAndHook< + ReturnType & { + setIsPaymentElementReady: (isPaymentElementReady: boolean) => void; + isPaymentElementReady: boolean; + checkout?: CommerceCheckoutResource; + paymentDescription?: string; + } +>('PaymentElementContext'); + +const [StripeUtilsContext, useStripeUtilsContext] = createContextAndHook<{ + stripe: Stripe | undefined | null; + elements: StripeElements | undefined | null; +}>('StripeUtilsContext'); + +const ValidateStripeUtils = ({ children }: PropsWithChildren) => { + const stripe = useStripe(); + const elements = useElements(); + + return {children}; +}; + +const DummyStripeUtils = ({ children }: PropsWithChildren) => { + return {children}; +}; + +type PaymentElementConfig = { + checkout?: CommerceCheckoutResource; + stripeAppearance?: internalStripeAppearance; + // TODO(@COMMERCE): What can we do to remove this ? + for: 'org' | 'user'; + paymentDescription?: string; +}; + +const PaymentElementProvider = (props: PropsWithChildren) => { + return ( + + + + ); +}; + +const PaymentElementInternalRoot = (props: PropsWithChildren) => { + const utils = usePaymentSourceUtils(props.for); + const { stripe, externalClientSecret } = utils; + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false); + + if (stripe && externalClientSecret) { + return ( + + + {props.children} + + + ); + } + + return ( + + {props.children} + + ); +}; + +const PaymentElement = ({ fallback }: { fallback?: ReactNode }) => { + const { setIsPaymentElementReady, paymentMethodOrder, checkout, stripe, externalClientSecret, paymentDescription } = + usePaymentElementContext(); + const environment = useInternalEnvironment(); + + if (!stripe || !externalClientSecret) { + return <>{fallback}; + } + + return ( + setIsPaymentElementReady(true)} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: false, + }, + paymentMethodOrder, + applePay: checkout + ? { + recurringPaymentRequest: { + paymentDescription: paymentDescription || '', + managementURL: environment?.displayConfig.homeUrl || '', // TODO(@COMMERCE): is this the right URL? + regularBilling: { + amount: checkout.totals.totalDueNow?.amount || checkout.totals.grandTotal.amount, + label: checkout.plan.name, + recurringPaymentIntervalUnit: checkout.planPeriod === 'annual' ? 'year' : 'month', + }, + }, + } + : undefined, + }} + /> + ); +}; + +const usePaymentElement = () => { + const { isPaymentElementReady, initializePaymentSource } = usePaymentElementContext(); + const { stripe, elements } = useStripeUtilsContext(); + const { stripe: stripeFromContext, externalClientSecret } = usePaymentElementContext(); + + const submit = useCallback(async () => { + if (!stripe || !elements) { + throw new Error('Stripe and Elements are not yet ready'); + } + + const { setupIntent, error } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: '', // TODO(@COMMERCE): need to figure this out + }, + redirect: 'if_required', + }); + if (error) { + return { data: null, error } as const; + } + return { + data: { gateway: 'stripe', paymentToken: setupIntent.payment_method as string }, + error: null, + } as const; + }, [stripe, elements]); + + const isProviderReady = stripe && externalClientSecret; + + return { + submit, + reset: initializePaymentSource, + isFormReady: isPaymentElementReady, + provider: isProviderReady + ? { + name: 'stripe', + instance: stripeFromContext, + } + : undefined, + isProviderReady: isProviderReady, + }; +}; + +export { + PaymentElementProvider as __experimental_PaymentElementProvider, + PaymentElement as __experimental_PaymentElement, + usePaymentElement as __experimental_usePaymentElement, +}; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 4b716f41052..29f23a611db 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -15,3 +15,5 @@ export { useSessionContext, useUserContext, } from './contexts'; + +export * from './commerce'; diff --git a/packages/shared/src/react/stripe-react.tsx b/packages/shared/src/react/stripe-react.tsx new file mode 100644 index 00000000000..4bb03049031 --- /dev/null +++ b/packages/shared/src/react/stripe-react.tsx @@ -0,0 +1,465 @@ +/** + * Original source: https://github.com/stripe/react-stripe-js. + * + * The current version of this file is a fork of the original version. + * The main difference is that we have kept only the necessary parts of the file. + * This is because we don't need it and it's not used in the Clerk codebase. + * + * The original version of this file is licensed under the MIT license. + * Https://github.com/stripe/react-stripe-js/blob/master/LICENSE. + */ + +import type { ElementProps, PaymentElementProps } from '@stripe/react-stripe-js'; +import type { + Stripe, + StripeElement, + StripeElements, + StripeElementsOptions, + StripeElementType, +} from '@stripe/stripe-js'; +import type { FunctionComponent, PropsWithChildren, ReactNode } from 'react'; +import React, { useState } from 'react'; + +import { useAttachEvent, usePrevious } from './use-previous'; + +interface ElementsContextValue { + elements: StripeElements | null; + stripe: Stripe | null; +} + +const ElementsContext = React.createContext(null); +ElementsContext.displayName = 'ElementsContext'; + +const parseElementsContext = (ctx: ElementsContextValue | null, useCase: string): ElementsContextValue => { + if (!ctx) { + throw new Error( + `Could not find Elements context; You need to wrap the part of your app that ${useCase} in an provider.`, + ); + } + + return ctx; +}; + +interface ElementsProps { + /** + * A [Stripe object](https://stripe.com/docs/js/initializing) or a `Promise` resolving to a `Stripe` object. + * The easiest way to initialize a `Stripe` object is with the the [Stripe.js wrapper module](https://github.com/stripe/stripe-js/blob/master/README.md#readme). + * Once this prop has been set, it can not be changed. + * + * You can also pass in `null` or a `Promise` resolving to `null` if you are performing an initial server-side render or when generating a static site. + */ + stripe: PromiseLike | Stripe | null; + + /** + * Optional [Elements configuration options](https://stripe.com/docs/js/elements_object/create). + * Once the stripe prop has been set, these options cannot be changed. + */ + options?: StripeElementsOptions; +} + +type UnknownOptions = { [k: string]: unknown }; + +interface PrivateElementsProps { + stripe: unknown; + options?: UnknownOptions; + children?: ReactNode; +} + +/** + * The `Elements` provider allows you to use [Element components](https://stripe.com/docs/stripe-js/react#element-components) and access the [Stripe object](https://stripe.com/docs/js/initializing) in any nested component. + * Render an `Elements` provider at the root of your React app so that it is available everywhere you need it. + * + * To use the `Elements` provider, call `loadStripe` from `@stripe/stripe-js` with your publishable key. + * The `loadStripe` function will asynchronously load the Stripe.js script and initialize a `Stripe` object. + * Pass the returned `Promise` to `Elements`. + * + * @docs https://stripe.com/docs/stripe-js/react#elements-provider + */ +const Elements: FunctionComponent> = (({ + stripe: rawStripeProp, + options, + children, +}: PrivateElementsProps) => { + const parsed = React.useMemo(() => parseStripeProp(rawStripeProp), [rawStripeProp]); + + // For a sync stripe instance, initialize into context + const [ctx, setContext] = React.useState(() => ({ + stripe: parsed.tag === 'sync' ? parsed.stripe : null, + elements: parsed.tag === 'sync' ? parsed.stripe.elements(options) : null, + })); + + React.useEffect(() => { + let isMounted = true; + + const safeSetContext = (stripe: Stripe) => { + setContext(ctx => { + // no-op if we already have a stripe instance (https://github.com/stripe/react-stripe-js/issues/296) + if (ctx.stripe) return ctx; + return { + stripe, + elements: stripe.elements(options), + }; + }); + }; + + // For an async stripePromise, store it in context once resolved + if (parsed.tag === 'async' && !ctx.stripe) { + parsed.stripePromise.then(stripe => { + if (stripe && isMounted) { + // Only update Elements context if the component is still mounted + // and stripe is not null. We allow stripe to be null to make + // handling SSR easier. + safeSetContext(stripe); + } + }); + } else if (parsed.tag === 'sync' && !ctx.stripe) { + // Or, handle a sync stripe instance going from null -> populated + safeSetContext(parsed.stripe); + } + + return () => { + isMounted = false; + }; + }, [parsed, ctx, options]); + + // Warn on changes to stripe prop + const prevStripe = usePrevious(rawStripeProp); + React.useEffect(() => { + if (prevStripe !== null && prevStripe !== rawStripeProp) { + console.warn('Unsupported prop change on Elements: You cannot change the `stripe` prop after setting it.'); + } + }, [prevStripe, rawStripeProp]); + + // Apply updates to elements when options prop has relevant changes + const prevOptions = usePrevious(options); + React.useEffect(() => { + if (!ctx.elements) { + return; + } + + const updates = extractAllowedOptionsUpdates(options, prevOptions, ['clientSecret', 'fonts']); + + if (updates) { + ctx.elements.update(updates); + } + }, [options, prevOptions, ctx.elements]); + + return {children}; +}) as FunctionComponent>; + +const useElementsContextWithUseCase = (useCaseMessage: string): ElementsContextValue => { + const ctx = React.useContext(ElementsContext); + return parseElementsContext(ctx, useCaseMessage); +}; + +const useElements = (): StripeElements | null => { + const { elements } = useElementsContextWithUseCase('calls useElements()'); + return elements; +}; + +const INVALID_STRIPE_ERROR = + 'Invalid prop `stripe` supplied to `Elements`. We recommend using the `loadStripe` utility from `@stripe/stripe-js`. See https://stripe.com/docs/stripe-js/react#elements-props-stripe for details.'; + +// We are using types to enforce the `stripe` prop in this lib, but in a real +// integration `stripe` could be anything, so we need to do some sanity +// validation to prevent type errors. +const validateStripe = (maybeStripe: unknown, errorMsg = INVALID_STRIPE_ERROR): null | Stripe => { + if (maybeStripe === null || isStripe(maybeStripe)) { + return maybeStripe; + } + + throw new Error(errorMsg); +}; + +type ParsedStripeProp = + | { tag: 'empty' } + | { tag: 'sync'; stripe: Stripe } + | { tag: 'async'; stripePromise: Promise }; + +const parseStripeProp = (raw: unknown, errorMsg = INVALID_STRIPE_ERROR): ParsedStripeProp => { + if (isPromise(raw)) { + return { + tag: 'async', + stripePromise: Promise.resolve(raw).then(result => validateStripe(result, errorMsg)), + }; + } + + const stripe = validateStripe(raw, errorMsg); + + if (stripe === null) { + return { tag: 'empty' }; + } + + return { tag: 'sync', stripe }; +}; + +const isUnknownObject = (raw: unknown): raw is { [key in PropertyKey]: unknown } => { + return raw !== null && typeof raw === 'object'; +}; + +const isPromise = (raw: unknown): raw is PromiseLike => { + return isUnknownObject(raw) && typeof raw.then === 'function'; +}; + +// We are using types to enforce the `stripe` prop in this lib, +// but in an untyped integration `stripe` could be anything, so we need +// to do some sanity validation to prevent type errors. +const isStripe = (raw: unknown): raw is Stripe => { + return ( + isUnknownObject(raw) && + typeof raw.elements === 'function' && + typeof raw.createToken === 'function' && + typeof raw.createPaymentMethod === 'function' && + typeof raw.confirmCardPayment === 'function' + ); +}; + +const extractAllowedOptionsUpdates = ( + options: unknown | void, + prevOptions: unknown | void, + immutableKeys: string[], +): UnknownOptions | null => { + if (!isUnknownObject(options)) { + return null; + } + + return Object.keys(options).reduce((newOptions: null | UnknownOptions, key) => { + const isUpdated = !isUnknownObject(prevOptions) || !isEqual(options[key], prevOptions[key]); + + if (immutableKeys.includes(key)) { + if (isUpdated) { + console.warn(`Unsupported prop change: options.${key} is not a mutable property.`); + } + + return newOptions; + } + + if (!isUpdated) { + return newOptions; + } + + return { ...(newOptions || {}), [key]: options[key] }; + }, null); +}; + +const PLAIN_OBJECT_STR = '[object Object]'; + +const isEqual = (left: unknown, right: unknown): boolean => { + if (!isUnknownObject(left) || !isUnknownObject(right)) { + return left === right; + } + + const leftArray = Array.isArray(left); + const rightArray = Array.isArray(right); + + if (leftArray !== rightArray) return false; + + const leftPlainObject = Object.prototype.toString.call(left) === PLAIN_OBJECT_STR; + const rightPlainObject = Object.prototype.toString.call(right) === PLAIN_OBJECT_STR; + + if (leftPlainObject !== rightPlainObject) return false; + + // not sure what sort of special object this is (regexp is one option), so + // fallback to reference check. + if (!leftPlainObject && !leftArray) return left === right; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) return false; + + const keySet: { [key: string]: boolean } = {}; + for (let i = 0; i < leftKeys.length; i += 1) { + keySet[leftKeys[i]] = true; + } + for (let i = 0; i < rightKeys.length; i += 1) { + keySet[rightKeys[i]] = true; + } + const allKeys = Object.keys(keySet); + if (allKeys.length !== leftKeys.length) { + return false; + } + + const l = left; + const r = right; + const pred = (key: string): boolean => { + return isEqual(l[key], r[key]); + }; + + return allKeys.every(pred); +}; + +const useStripe = (): Stripe | null => { + const { stripe } = useElementsOrCheckoutSdkContextWithUseCase('calls useStripe()'); + return stripe; +}; + +const useElementsOrCheckoutSdkContextWithUseCase = (useCaseString: string): ElementsContextValue => { + const elementsContext = React.useContext(ElementsContext); + + return parseElementsContext(elementsContext, useCaseString); +}; + +type UnknownCallback = (...args: unknown[]) => any; + +interface PrivateElementProps { + id?: string; + className?: string; + fallback?: ReactNode; + onChange?: UnknownCallback; + onBlur?: UnknownCallback; + onFocus?: UnknownCallback; + onEscape?: UnknownCallback; + onReady?: UnknownCallback; + onClick?: UnknownCallback; + onLoadError?: UnknownCallback; + onLoaderStart?: UnknownCallback; + onNetworksChange?: UnknownCallback; + onConfirm?: UnknownCallback; + onCancel?: UnknownCallback; + onShippingAddressChange?: UnknownCallback; + onShippingRateChange?: UnknownCallback; + options?: UnknownOptions; +} + +const capitalized = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +const createElementComponent = (type: StripeElementType, isServer: boolean): FunctionComponent => { + const displayName = `${capitalized(type)}Element`; + + const ClientElement: FunctionComponent = ({ + id, + className, + fallback, + options = {}, + onBlur, + onFocus, + onReady, + onChange, + onEscape, + onClick, + onLoadError, + onLoaderStart, + onNetworksChange, + onConfirm, + onCancel, + onShippingAddressChange, + onShippingRateChange, + }) => { + const ctx = useElementsOrCheckoutSdkContextWithUseCase(`mounts <${displayName}>`); + const elements = 'elements' in ctx ? ctx.elements : null; + const [element, setElement] = React.useState(null); + const elementRef = React.useRef(null); + const domNode = React.useRef(null); + const [isReady, setReady] = useState(false); + + // For every event where the merchant provides a callback, call element.on + // with that callback. If the merchant ever changes the callback, removes + // the old callback with element.off and then call element.on with the new one. + useAttachEvent(element, 'blur', onBlur); + useAttachEvent(element, 'focus', onFocus); + useAttachEvent(element, 'escape', onEscape); + useAttachEvent(element, 'click', onClick); + useAttachEvent(element, 'loaderror', onLoadError); + useAttachEvent(element, 'loaderstart', onLoaderStart); + useAttachEvent(element, 'networkschange', onNetworksChange); + useAttachEvent(element, 'confirm', onConfirm); + useAttachEvent(element, 'cancel', onCancel); + useAttachEvent(element, 'shippingaddresschange', onShippingAddressChange); + useAttachEvent(element, 'shippingratechange', onShippingRateChange); + useAttachEvent(element, 'change', onChange); + + let readyCallback: UnknownCallback | undefined; + if (onReady) { + // For other Elements, pass through the Element itself. + readyCallback = () => { + setReady(true); + onReady(element); + }; + } + + useAttachEvent(element, 'ready', readyCallback); + + React.useLayoutEffect(() => { + if (elementRef.current === null && domNode.current !== null && elements) { + let newElement: StripeElement | null = null; + if (elements) { + newElement = elements.create(type as any, options); + } + + // Store element in a ref to ensure it's _immediately_ available in cleanup hooks in StrictMode + elementRef.current = newElement; + // Store element in state to facilitate event listener attachment + setElement(newElement); + + if (newElement) { + newElement.mount(domNode.current); + } + } + }, [elements, options]); + + const prevOptions = usePrevious(options); + React.useEffect(() => { + if (!elementRef.current) { + return; + } + + const updates = extractAllowedOptionsUpdates(options, prevOptions, ['paymentRequest']); + + if (updates && 'update' in elementRef.current) { + elementRef.current.update(updates); + } + }, [options, prevOptions]); + + React.useLayoutEffect(() => { + return () => { + if (elementRef.current && typeof elementRef.current.destroy === 'function') { + try { + elementRef.current.destroy(); + elementRef.current = null; + } catch { + // Do nothing + } + } + }; + }, []); + + return ( + <> + {!isReady && fallback} +
+ + ); + }; + + // Only render the Element wrapper in a server environment. + const ServerElement: FunctionComponent = props => { + useElementsOrCheckoutSdkContextWithUseCase(`mounts <${displayName}>`); + const { id, className } = props; + return ( +
+ ); + }; + + const Element = isServer ? ServerElement : ClientElement; + Element.displayName = displayName; + (Element as any).__elementType = type; + + return Element as FunctionComponent; +}; + +const isServer = typeof window === 'undefined'; +const PaymentElement: FunctionComponent< + PaymentElementProps & { + fallback?: ReactNode; + } +> = createElementComponent('payment', isServer); + +export { Elements, useElements, useStripe, PaymentElement }; diff --git a/packages/shared/src/react/use-previous.ts b/packages/shared/src/react/use-previous.ts new file mode 100644 index 00000000000..aabdfa0babf --- /dev/null +++ b/packages/shared/src/react/use-previous.ts @@ -0,0 +1,45 @@ +import type { StripeElement } from '@stripe/stripe-js'; +import { useEffect, useRef } from 'react'; + +export const usePrevious = (value: T): T => { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; + +export const useAttachEvent = ( + element: StripeElement | null, + event: string, + cb?: (...args: A) => any, +) => { + const cbDefined = !!cb; + const cbRef = useRef(cb); + + // In many integrations the callback prop changes on each render. + // Using a ref saves us from calling element.on/.off every render. + useEffect(() => { + cbRef.current = cb; + }, [cb]); + + useEffect(() => { + if (!cbDefined || !element) { + return () => {}; + } + + const decoratedCb = (...args: A): void => { + if (cbRef.current) { + cbRef.current(...args); + } + }; + + (element as any).on(event, decoratedCb); + + return () => { + (element as any).off(event, decoratedCb); + }; + }, [cbDefined, event, element, cbRef]); +}; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 296c327d52e..fd0feb2b204 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,9 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bf0fd935b27..1953c48a7f2 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -494,6 +494,12 @@ export interface Clerk { */ __internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void; + /** + * @internal + * Loads Stripe libraries for commerce functionality + */ + __internal_loadStripeJs: () => Promise; + /** * Register a listener that triggers a callback each time important Clerk resources are changed. * Allows to hook up at different steps in the sign up, sign in processes. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 692fcd87397..f4022b5645c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,9 +455,6 @@ importers: '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 - '@stripe/react-stripe-js': - specifier: 3.1.1 - version: 3.1.1(@stripe/stripe-js@5.6.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@stripe/stripe-js': specifier: 5.6.0 version: 5.6.0 @@ -935,6 +932,12 @@ importers: specifier: ^2.3.3 version: 2.3.3(react@18.3.1) devDependencies: + '@stripe/react-stripe-js': + specifier: 3.1.1 + version: 3.1.1(@stripe/stripe-js@5.6.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@stripe/stripe-js': + specifier: 5.6.0 + version: 5.6.0 '@types/glob-to-regexp': specifier: 0.4.4 version: 0.4.4 @@ -2720,7 +2723,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==}