diff --git a/.changeset/cold-bottles-watch.md b/.changeset/cold-bottles-watch.md
new file mode 100644
index 00000000000..a525e856df1
--- /dev/null
+++ b/.changeset/cold-bottles-watch.md
@@ -0,0 +1,6 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/shared': patch
+---
+
+Propagate locale from ClerkProvider to PaymentElement
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index e68823116c3..75d2a2b28c7 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -2420,6 +2420,7 @@ export class Clerk implements ClerkInterface {
..._props,
options: this.#initOptions({ ...this.#options, ..._props.options }),
};
+
return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props));
};
diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/commerce.test.tsx
new file mode 100644
index 00000000000..29cc56c5ac4
--- /dev/null
+++ b/packages/shared/src/react/__tests__/commerce.test.tsx
@@ -0,0 +1,250 @@
+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';
+
+// Mock the Stripe components
+vi.mock('../stripe-react', () => ({
+ Elements: ({ children, options }: { children: React.ReactNode; options: any }) => (
+
+ {children}
+
+ ),
+ PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => {fallback}
,
+ useElements: () => null,
+ useStripe: () => null,
+}));
+
+// Mock the hooks
+const mockGetOption = vi.fn();
+vi.mock('../hooks/useClerk', () => ({
+ useClerk: () => ({
+ __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',
+ },
+ },
+ }),
+}));
+
+vi.mock('../hooks/useUser', () => ({
+ useUser: () => ({
+ user: {
+ id: 'user_123',
+ initializePaymentSource: vi.fn().mockResolvedValue({
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ }),
+ },
+ }),
+}));
+
+vi.mock('../hooks/useOrganization', () => ({
+ useOrganization: () => ({
+ organization: null,
+ }),
+}));
+
+vi.mock('swr', () => ({
+ __esModule: true,
+ default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }),
+}));
+
+vi.mock('swr/mutation', () => ({
+ __esModule: true,
+ default: () => ({
+ data: {
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ },
+ trigger: vi.fn().mockResolvedValue({
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ }),
+ }),
+}));
+
+describe('PaymentElement Localization', () => {
+ const mockCheckout = {
+ id: 'checkout_123',
+ needsPaymentMethod: true,
+ plan: {
+ id: 'plan_123',
+ name: 'Test Plan',
+ description: 'Test plan description',
+ fee: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
+ annualFee: { amount: 10000, amountFormatted: '$100.00', currency: 'usd', currencySymbol: '$' },
+ annualMonthlyFee: { amount: 833, amountFormatted: '$8.33', currency: 'usd', currencySymbol: '$' },
+ currency: 'usd',
+ interval: 'month' as const,
+ intervalCount: 1,
+ maxAllowedInstances: 1,
+ trialDays: 0,
+ isAddon: false,
+ isPopular: false,
+ isPerSeat: false,
+ isUsageBased: false,
+ isFree: false,
+ isLegacy: false,
+ isDefault: false,
+ isRecurring: true,
+ hasBaseFee: true,
+ forPayerType: 'user' as const,
+ publiclyVisible: true,
+ slug: 'test-plan',
+ avatarUrl: '',
+ freeTrialDays: 0,
+ freeTrialEnabled: false,
+ pathRoot: '/',
+ reload: vi.fn(),
+ features: [],
+ limits: {},
+ metadata: {},
+ },
+ totals: {
+ subtotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
+ grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
+ taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
+ totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
+ credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
+ pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
+ },
+ status: 'needs_confirmation' as const,
+ error: null,
+ fetchStatus: 'idle' as const,
+ confirm: vi.fn(),
+ start: vi.fn(),
+ clear: vi.fn(),
+ finalize: vi.fn(),
+ getState: vi.fn(),
+ isConfirming: false,
+ isStarting: false,
+ planPeriod: 'month' as const,
+ externalClientSecret: 'seti_123',
+ externalGatewayId: 'acct_123',
+ isImmediatePlanChange: false,
+ paymentMethodOrder: ['card'],
+ freeTrialEndsAt: null,
+ payer: {
+ id: 'payer_123',
+ createdAt: new Date('2023-01-01'),
+ updatedAt: new Date('2023-01-01'),
+ imageUrl: null,
+ userId: 'user_123',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ organizationId: undefined,
+ organizationName: undefined,
+ pathRoot: '/',
+ reload: vi.fn(),
+ },
+ };
+
+ const renderWithLocale = (locale: string) => {
+ // Mock the __internal_getOption to return the expected localization
+ mockGetOption.mockImplementation(key => {
+ if (key === 'localization') {
+ return { locale };
+ }
+ return undefined;
+ });
+
+ const options = {
+ localization: { locale },
+ };
+
+ return render(
+
+ <__experimental_PaymentElementProvider checkout={mockCheckout}>
+ <__experimental_PaymentElement fallback={Loading...
} />
+
+ ,
+ );
+ };
+
+ it('should pass the correct locale to Stripe Elements', () => {
+ renderWithLocale('es');
+
+ const elements = screen.getByTestId('stripe-elements');
+ expect(elements.getAttribute('data-locale')).toBe('es');
+ });
+
+ it('should default to "en" when no locale is provided', () => {
+ // Mock the __internal_getOption to return undefined for localization
+ mockGetOption.mockImplementation(key => {
+ if (key === 'localization') {
+ return undefined;
+ }
+ return undefined;
+ });
+
+ const options = {};
+
+ render(
+
+ <__experimental_PaymentElementProvider checkout={mockCheckout}>
+ <__experimental_PaymentElement fallback={Loading...
} />
+
+ ,
+ );
+
+ const elements = screen.getByTestId('stripe-elements');
+ expect(elements.getAttribute('data-locale')).toBe('en');
+ });
+
+ it('should normalize full locale strings to 2-letter codes for Stripe', () => {
+ const testCases = [
+ { input: 'en', expected: 'en' },
+ { input: 'en-US', expected: 'en' },
+ { input: 'fr-FR', expected: 'fr' },
+ { input: 'es-ES', expected: 'es' },
+ { input: 'de-DE', expected: 'de' },
+ { input: 'it-IT', expected: 'it' },
+ { input: 'pt-BR', expected: 'pt' },
+ ];
+
+ testCases.forEach(({ input, expected }) => {
+ // Mock the __internal_getOption to return the expected localization
+ mockGetOption.mockImplementation(key => {
+ if (key === 'localization') {
+ return { locale: input };
+ }
+ return undefined;
+ });
+
+ const options = {
+ localization: { locale: input },
+ };
+
+ const { unmount } = render(
+
+ <__experimental_PaymentElementProvider checkout={mockCheckout}>
+ <__experimental_PaymentElement fallback={Loading...
} />
+
+ ,
+ );
+
+ const elements = screen.getByTestId('stripe-elements');
+ expect(elements.getAttribute('data-locale')).toBe(expected);
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx
index 336f7449508..f1d55aaa463 100644
--- a/packages/shared/src/react/commerce.tsx
+++ b/packages/shared/src/react/commerce.tsx
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
-import type { Stripe, StripeElements } from '@stripe/stripe-js';
+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';
@@ -62,6 +62,23 @@ const useInternalEnvironment = () => {
return clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined;
};
+const useLocalization = () => {
+ const clerk = useClerk();
+
+ let locale = 'en';
+ try {
+ const localization = clerk.__internal_getOption('localization');
+ locale = localization?.locale || 'en';
+ } catch {
+ // ignore errors
+ }
+
+ // Normalize locale to 2-letter language code for Stripe compatibility
+ const normalizedLocale = locale.split('-')[0];
+
+ return normalizedLocale;
+};
+
const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => {
const { organization } = useOrganization();
const { user } = useUser();
@@ -206,6 +223,7 @@ const PaymentElementProvider = ({ children, ...props }: PropsWithChildren {
const { stripe, externalClientSecret, stripeAppearance } = usePaymentElementContext();
+ const locale = useLocalization();
if (stripe && externalClientSecret) {
return (
@@ -219,6 +237,7 @@ const PaymentElementInternalRoot = (props: PropsWithChildren) => {
appearance: {
variables: stripeAppearance,
},
+ locale: locale as StripeElementsOptions['locale'],
}}
>
{props.children}