diff --git a/.changeset/cold-bottles-watch.md b/.changeset/cold-bottles-watch.md
new file mode 100644
index 00000000000..e57ff6acfba
--- /dev/null
+++ b/.changeset/cold-bottles-watch.md
@@ -0,0 +1,6 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/shared': patch
+---
+
+Pass current localization value to Stripe Elements
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 15d45afc722..f9793b0918a 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -237,19 +237,20 @@ function otherOptions() {
});
const updateOtherOptions = () => {
- void Clerk.__unstable__updateProps({
- options: Object.fromEntries(
- Object.entries(otherOptionsInputs).map(([key, input]) => {
- sessionStorage.setItem(key, input.value);
+ const options = Object.fromEntries(
+ Object.entries(otherOptionsInputs).map(([key, input]) => {
+ sessionStorage.setItem(key, input.value);
- if (key === 'localization') {
- return [key, l[input.value as keyof typeof l]];
- }
+ if (key === 'localization') {
+ const localizationObj = l[input.value as keyof typeof l];
+ return [key, localizationObj];
+ }
- return [key, input.value];
- }),
- ),
- });
+ return [key, input.value];
+ }),
+ );
+
+ void Clerk.__unstable__updateProps({ options });
};
Object.values(otherOptionsInputs).forEach(input => {
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 6e676e2d371..c014cdb39b0 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -2375,10 +2375,19 @@ export class Clerk implements ClerkInterface {
// 2. clerk-js initializes propA with a default value
// 3. The customer update propB independently of propA and window.Clerk.updateProps is called
// 4. If we don't merge the new props with the current options, propA will be reset to undefined
+ const mergedOptions = { ...this.#options, ..._props.options };
+
+ // Process the merged options to ensure consistency between internal state and emitted props
+ const processedOptions = this.#initOptions(mergedOptions);
+
+ // Update the Clerk instance's internal options with processed data
+ this.#options = processedOptions;
+
const props = {
..._props,
- options: this.#initOptions({ ...this.#options, ..._props.options }),
+ options: processedOptions,
};
+
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..9d090d8dff3
--- /dev/null
+++ b/packages/shared/src/react/__tests__/commerce.test.tsx
@@ -0,0 +1,322 @@
+import '@testing-library/jest-dom';
+
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce';
+import { OptionsContext } from '../contexts';
+
+// Mock the Stripe components
+jest.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 = jest.fn();
+jest.mock('../hooks/useClerk', () => ({
+ useClerk: () => ({
+ __internal_loadStripeJs: jest.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',
+ },
+ },
+ }),
+}));
+
+jest.mock('../hooks/useUser', () => ({
+ useUser: () => ({
+ user: {
+ id: 'user_123',
+ initializePaymentSource: jest.fn().mockResolvedValue({
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ }),
+ },
+ }),
+}));
+
+jest.mock('../hooks/useOrganization', () => ({
+ useOrganization: () => ({
+ organization: null,
+ }),
+}));
+
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: () => ({ data: { loadStripe: jest.fn().mockResolvedValue({}) } }),
+}));
+
+jest.mock('swr/mutation', () => ({
+ __esModule: true,
+ default: () => ({
+ data: {
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ },
+ trigger: jest.fn().mockResolvedValue({
+ externalGatewayId: 'acct_123',
+ externalClientSecret: 'seti_123',
+ paymentMethodOrder: ['card'],
+ }),
+ }),
+}));
+
+describe('PaymentElement Localization', () => {
+ const mockCheckout = {
+ id: 'checkout_123',
+ 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: jest.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: jest.fn(),
+ start: jest.fn(),
+ clear: jest.fn(),
+ finalize: jest.fn(),
+ getState: jest.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: jest.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).toHaveAttribute('data-locale', '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).toHaveAttribute('data-locale', 'en');
+ });
+
+ it('should handle different locale values', () => {
+ const locales = ['en', 'es', 'fr', 'de', 'it'];
+
+ locales.forEach(locale => {
+ const { unmount } = renderWithLocale(locale);
+
+ const elements = screen.getByTestId('stripe-elements');
+ expect(elements).toHaveAttribute('data-locale', locale);
+
+ unmount();
+ });
+ });
+
+ it('should handle undefined localization object', () => {
+ // Mock the __internal_getOption to return undefined for localization
+ mockGetOption.mockImplementation(key => {
+ if (key === 'localization') {
+ return undefined;
+ }
+ return undefined;
+ });
+
+ const options = {
+ localization: undefined,
+ };
+
+ render(
+
+ <__experimental_PaymentElementProvider checkout={mockCheckout}>
+ <__experimental_PaymentElement fallback={Loading...
} />
+
+ ,
+ );
+
+ const elements = screen.getByTestId('stripe-elements');
+ expect(elements).toHaveAttribute('data-locale', 'en');
+ });
+
+ it('should work with full LocalizationResource structure like ClerkProvider', () => {
+ // Mock the __internal_getOption to return the expected localization
+ mockGetOption.mockImplementation(key => {
+ if (key === 'localization') {
+ return { locale: 'fr-FR' };
+ }
+ return undefined;
+ });
+
+ // This test simulates the actual ClerkProvider usage pattern:
+ // import { frFR } from '@clerk/localizations';
+ //
+ const options = {
+ localization: {
+ locale: 'fr-FR',
+ // This would normally contain all the translation strings from frFR
+ // but we only need the locale property for our implementation
+ },
+ };
+
+ render(
+
+ <__experimental_PaymentElementProvider checkout={mockCheckout}>
+ <__experimental_PaymentElement fallback={Loading...
} />
+
+ ,
+ );
+
+ const elements = screen.getByTestId('stripe-elements');
+ // Should normalize 'fr-FR' to 'fr' for Stripe compatibility
+ expect(elements).toHaveAttribute('data-locale', 'fr');
+ });
+
+ it('should normalize full locale strings to 2-letter codes for Stripe', () => {
+ const testCases = [
+ { 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).toHaveAttribute('data-locale', expected);
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx
index 72fd978f4b1..ca143a6aa74 100644
--- a/packages/shared/src/react/commerce.tsx
+++ b/packages/shared/src/react/commerce.tsx
@@ -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 any,
}}
>
{props.children}