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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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();
});
});
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Normalization may drop supported regional locales (risk of worse UX)

The test enforces unconditional 2-letter normalization (e.g., pt-BR β†’ pt). Stripe Elements accepts several region codes (e.g., pt-BR, fr-CA, en-GB, es-419, zh-TW). Collapsing these to base language can degrade translations.

Recommendation:

  • Preserve locale if it’s in Stripe’s supported list (including region variants).
  • Otherwise, try base language; finally fall back to en.

Update tests to reflect preservation where applicable.

- 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' },- ];+ const testCases = [+ // preserve known region variants supported by Stripe+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-TW', expected: 'zh-TW' },+ // collapse to base where region is not needed/supported+ { 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' },+ ];

To confirm Stripe’s current supported locales list:


🌐 Web query:

What locales are supported by Stripe Elements as of September 2025? Does it accept region codes like en-GB, fr-CA, es-419, pt-BR, zh-TW?

πŸ’‘ Result:

Short answer: Yes β€” Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE β†’ fr-FR). [1][2]

Sources:

  • Stripe Elements / Connect embedded components supported locales (includes en-GB, fr-CA, es-419, pt-BR, zh-Hant-TW, zh-Hans). [1]
  • Stripe Checkout / locale support & fallback behavior. [2]

Preserve Stripe-supported locale variants in normalization/tests

The current logic collapses every locale (e.g., pt-BR, en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:

  • Use the full locale if it’s in Stripe’s supported list
  • Otherwise fall back to the two-letter language tag
  • Finally fall back to en as a last resort

Example test update:

- const testCases = [- { input: 'en-US', expected: 'en' },- { input: 'fr-FR', expected: 'fr' },- // …- ];+ const testCases = [+ // preserve supported region/script variants+ { input: 'en-GB', expected: 'en-GB' },+ { input: 'fr-CA', expected: 'fr-CA' },+ { input: 'es-419', expected: 'es-419' },+ { input: 'pt-BR', expected: 'pt-BR' },+ { input: 'zh-Hans', expected: 'zh-Hans' },+ { input: 'zh-Hant-TW', expected: 'zh-Hant-TW' },+ // collapse unsupported variants+ { input: 'en-US', expected: 'en' },+ { input: 'fr-FR', expected: 'fr' },+ { input: 'es-ES', expected: 'es' },+ // …+ ];
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
consttestCases=[
{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};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe',()=>{
-consttestCases=[
-{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'},
consttestCases=[
// preserve Stripe-supported region/script variants
{input: 'en-GB',expected: 'en-GB'},
{input: 'fr-CA',expected: 'fr-CA'},
{input: 'es-419',expected: 'es-419'},
{input: 'pt-BR',expected: 'pt-BR'},
{input: 'zh-Hans',expected: 'zh-Hans'},
{input: 'zh-Hant-TW',expected: 'zh-Hant-TW'},
// fall back to 2-letter language code for unsupported variants
{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'},
];
testCases.forEach(({ input, expected })=>{
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key=>{
if(key==='localization'){
return{locale: input};
}
returnundefined;
});
constoptions={
localization: {locale: input},
};
const{ unmount }=render(
<OptionsContext.Providervalue={options}>
<__experimental_PaymentElementProvidercheckout={mockCheckout}>
<__experimental_PaymentElementfallback={<div>Loading...</div>}/>
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);
constelements=screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale',expected);
unmount();
});
});
πŸ€– Prompt for AI Agents
In packages/shared/src/react/__tests__/commerce.test.tsx around lines 285-321,
the test and underlying normalization collapse every locale to its 2-letter base
but Stripe supports specific region/script variants; change the normalization
logic to first check against Stripe's supported-locale list and return the full
incoming locale if present, otherwise fall back to the two-letter language
subtag, and as a final fallback return 'en'; update these tests to include
supported variants (e.g., 'en-GB', 'pt-BR', 'es-419', 'zh-Hans', 'zh-Hant-TW')
asserting the full variant is preserved, keep existing cases asserting
base-language fallback for unsupported variants, and ensure the mockGetOption
and OptionsContext values reflect the exact locale strings used in each case.

});
21 changes: 20 additions & 1 deletion packages/shared/src/react/commerce.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
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-BR β†’ pt or zh-Hant-TW β†’ zh 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 DownExpand 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