Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .changeset/cold-bottles-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Propagate locale from ClerkProvider to PaymentElement
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};

Expand Down
250 changes: 250 additions & 0 deletions packages/shared/src/react/__tests__/commerce.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div
data-testid='stripe-elements'
data-locale={options.locale}
>
{children}
</div>
),
PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => <div>{fallback}</div>,
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(
<OptionsContext.Provider value={options}>
<__experimental_PaymentElementProvider checkout={mockCheckout}>
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
};

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(
<OptionsContext.Provider value={options}>
<__experimental_PaymentElementProvider checkout={mockCheckout}>
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);

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(
<OptionsContext.Provider value={options}>
<__experimental_PaymentElementProvider checkout={mockCheckout}>
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);

const elements = screen.getByTestId('stripe-elements');
expect(elements.getAttribute('data-locale')).toBe(expected);

unmount();
});
});
});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
};
Comment on lines +65 to +80
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 | 🔴 Critical

🧩 Analysis chain

Normalization drops Stripe-supported regional locales, degrading translation quality.

The current implementation unconditionally normalizes all locales to 2-letter language codes (line 77), but Stripe Elements supports specific regional variants that provide better translations:

  • Regional variants: en-GB, fr-CA, es-419, pt-BR
  • Script variants: zh-Hans, zh-Hant-TW, zh-Hant-HK

Collapsing pt-BRpt or zh-Hant-TWzh can result in incorrect or less relevant translations for users in those regions.

Additionally, line 72-74 silently swallows all errors without logging, making debugging difficult.

Based on learnings from the past review comment, update the normalization logic:

 const useLocalization = () => {
   const clerk = useClerk();
 
   let locale = 'en';
   try {
     const localization = clerk.__internal_getOption('localization');
     locale = localization?.locale || 'en';
-  } catch {
+  } catch (error) {
     // ignore errors
+    console.warn('Failed to retrieve localization option:', error);
   }
 
-  // Normalize locale to 2-letter language code for Stripe compatibility
-  const normalizedLocale = locale.split('-')[0];
+  // Stripe supports specific regional/script variants; preserve them if valid
+  const stripeLocales = new Set([
+    'auto', 'ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'en-GB', 
+    'es', 'es-419', 'et', 'fi', 'fil', 'fr', 'fr-CA', 'he', 'hr',
+    'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'ms', 'mt', 'nb',
+    'nl', 'no', 'pl', 'pt', 'pt-BR', 'ro', 'ru', 'sk', 'sl', 'sv',
+    'th', 'tr', 'vi', 'zh', 'zh-Hans', 'zh-Hant', 'zh-Hant-HK', 'zh-Hant-TW'
+  ]);
+  
+  // Validate locale is a string before processing
+  if (typeof locale !== 'string') {
+    return 'en';
+  }
+
+  // Return as-is if supported; otherwise try base language; finally default to 'en'
+  if (stripeLocales.has(locale)) {
+    return locale;
+  }
+  
+  const baseLanguage = locale.split('-')[0];
+  return stripeLocales.has(baseLanguage) ? baseLanguage : 'en';
-
-  return normalizedLocale;
 };

Verify the current Stripe-supported locale list:


🌐 Web query:

What are the complete list of supported locales for Stripe Elements as of 2025?

💡 Result:

Here are the locales Stripe Elements supports (locale codes) as listed in Stripe’s docs (2025):

bg-BG, zh-Hans, zh-Hant-HK, zh-Hant-TW, hr-HR, cs-CZ, da-DK, nl-NL, en-AU, en-IN, en-IE, en-NZ, en-SG, en-GB, en-US, et-EE, fil-PH, fi-FI, fr-CA, fr-FR, de-DE, el-GR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, lv-LV, lt-LT, ms-MY, mt-MT, nb-NO, pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, es-AR, es-BR, es-419, es-MX, es-ES, sv-SE, th-TH, tr-TR, vi-VN. [1][2]

Sources:

  1. Stripe Connect embedded components — supported locales list (Stripe docs). [Stripe]. [2]
  2. Stripe JS appendix: Supported locales (Stripe docs). [2]

Corrected Stripe-supported locale list required; proposed solution includes unsupported locales.

The review comment correctly identifies that the current normalization degrades UX by collapsing regional locales, but the proposed fix contains inaccuracies:

The actual Stripe Elements 2025 supported locales use full locale codes with regions: bg-BG, zh-Hans, zh-Hant-HK, zh-Hant-TW, hr-HR, cs-CZ, da-DK, nl-NL, en-AU, en-IN, en-IE, en-NZ, en-SG, en-GB, en-US, et-EE, fil-PH, fi-FI, fr-CA, fr-FR, de-DE, el-GR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, lv-LV, lt-LT, ms-MY, mt-MT, nb-NO, pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, es-AR, es-BR, es-419, es-MX, es-ES, sv-SE, th-TH, tr-TR, vi-VN.

The proposed solution includes unsupported locales ('auto', 'ar', 'he', 'no', 'ru') and base language codes ('en', 'es', 'de') that Stripe doesn't accept as fallbacks. The corrected diff should use only validated Stripe locales and improve the fallback strategy.


const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => {
const { organization } = useOrganization();
const { user } = useUser();
Expand Down Expand Up @@ -206,6 +223,7 @@ const PaymentElementProvider = ({ children, ...props }: PropsWithChildren<Paymen

const PaymentElementInternalRoot = (props: PropsWithChildren) => {
const { stripe, externalClientSecret, stripeAppearance } = usePaymentElementContext();
const locale = useLocalization();

if (stripe && externalClientSecret) {
return (
Expand All @@ -219,6 +237,7 @@ const PaymentElementInternalRoot = (props: PropsWithChildren) => {
appearance: {
variables: stripeAppearance,
},
locale: locale as StripeElementsOptions['locale'],
}}
>
<ValidateStripeUtils>{props.children}</ValidateStripeUtils>
Expand Down