Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cold-bottles-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Pass current localization value to Stripe Elements
23 changes: 12 additions & 11 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,19 +237,20 @@ function otherOptions() {
});

const updateOtherOptions = () => {
void Clerk.__unstable__updateProps({
options: Object.fromEntries(
Object.entries(otherOptionsInputs).map(([key, input]) => {
sessionStorage.setItem(key, input.value);
const options = Object.fromEntries(
Object.entries(otherOptionsInputs).map(([key, input]) => {
sessionStorage.setItem(key, input.value);

if (key === 'localization') {
return [key, l[input.value as keyof typeof l]];
}
if (key === 'localization') {
const localizationObj = l[input.value as keyof typeof l];
return [key, localizationObj];
}

return [key, input.value];
}),
),
});
return [key, input.value];
}),
);

void Clerk.__unstable__updateProps({ options });
};

Object.values(otherOptionsInputs).forEach(input => {
Expand Down
8 changes: 7 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2375,10 +2375,16 @@ export class Clerk implements ClerkInterface {
// 2. clerk-js initializes propA with a default value
// 3. The customer update propB independently of propA and window.Clerk.updateProps is called
// 4. If we don't merge the new props with the current options, propA will be reset to undefined
const mergedOptions = { ...this.#options, ..._props.options };

// Update the Clerk instance's internal options
this.#options = mergedOptions;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm unsure if there is a reason we weren't doing this previously, but without it clerk.__internal_getOption('localization') would not work after __unstable__updateProps was called in the sandbox


const props = {
..._props,
options: this.#initOptions({ ...this.#options, ..._props.options }),
options: this.#initOptions(mergedOptions),
};

return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props));
};

Expand Down
321 changes: 321 additions & 0 deletions packages/shared/src/react/__tests__/commerce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import { render, screen } from '@testing-library/react';

Check failure on line 1 in packages/shared/src/react/__tests__/commerce.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

Run autofix to sort these imports!
import '@testing-library/jest-dom';
import React from 'react';

import { OptionsContext } from '../contexts';
import { __experimental_PaymentElementProvider, __experimental_PaymentElement } from '../commerce';

// Mock the Stripe components
jest.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 data-testid='stripe-payment-element'>{fallback}</div>
),
useElements: () => null,
useStripe: () => null,
}));

// Mock the hooks
const mockGetOption = jest.fn();
jest.mock('../hooks/useClerk', () => ({
useClerk: () => ({
__internal_loadStripeJs: jest.fn().mockResolvedValue(() => Promise.resolve({})),
__internal_getOption: mockGetOption,
__unstable__environment: {
commerceSettings: {
billing: {
stripePublishableKey: 'pk_test_123',
},
},
displayConfig: {
userProfileUrl: 'https://example.com/profile',
organizationProfileUrl: 'https://example.com/org-profile',
},
},
}),
}));

jest.mock('../hooks/useUser', () => ({
useUser: () => ({
user: {
id: 'user_123',
initializePaymentSource: jest.fn().mockResolvedValue({
externalGatewayId: 'acct_123',
externalClientSecret: 'seti_123',
paymentMethodOrder: ['card'],
}),
},
}),
}));

jest.mock('../hooks/useOrganization', () => ({
useOrganization: () => ({
organization: null,
}),
}));

jest.mock('swr', () => ({
__esModule: true,
default: () => ({ data: { loadStripe: jest.fn().mockResolvedValue({}) } }),
}));

jest.mock('swr/mutation', () => ({
__esModule: true,
default: () => ({
data: {
externalGatewayId: 'acct_123',
externalClientSecret: 'seti_123',
paymentMethodOrder: ['card'],
},
trigger: jest.fn().mockResolvedValue({
externalGatewayId: 'acct_123',
externalClientSecret: 'seti_123',
paymentMethodOrder: ['card'],
}),
}),
}));

describe('PaymentElement Localization', () => {
const mockCheckout = {
id: 'checkout_123',
plan: {
id: 'plan_123',
name: 'Test Plan',
description: 'Test plan description',
fee: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
annualFee: { amount: 10000, amountFormatted: '$100.00', currency: 'usd', currencySymbol: '$' },
annualMonthlyFee: { amount: 833, amountFormatted: '$8.33', currency: 'usd', currencySymbol: '$' },
currency: 'usd',
interval: 'month' as const,
intervalCount: 1,
maxAllowedInstances: 1,
trialDays: 0,
isAddon: false,
isPopular: false,
isPerSeat: false,
isUsageBased: false,
isFree: false,
isLegacy: false,
isDefault: false,
isRecurring: true,
hasBaseFee: true,
forPayerType: 'user' as const,
publiclyVisible: true,
slug: 'test-plan',
avatarUrl: '',
freeTrialDays: 0,
freeTrialEnabled: false,
pathRoot: '/',
reload: jest.fn(),
features: [],
limits: {},
metadata: {},
},
totals: {
subtotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
},
status: 'needs_confirmation' as const,
error: null,
fetchStatus: 'idle' as const,
confirm: jest.fn(),
start: jest.fn(),
clear: jest.fn(),
finalize: jest.fn(),
getState: jest.fn(),
isConfirming: false,
isStarting: false,
planPeriod: 'month' as const,
externalClientSecret: 'seti_123',
externalGatewayId: 'acct_123',
isImmediatePlanChange: false,
paymentMethodOrder: ['card'],
freeTrialEndsAt: null,
payer: {
id: 'payer_123',
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
imageUrl: null,
userId: 'user_123',
email: '[email protected]',
firstName: 'Test',
lastName: 'User',
organizationId: undefined,
organizationName: undefined,
pathRoot: '/',
reload: jest.fn(),
},
};

const renderWithLocale = (locale: string) => {
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return { locale };
}
return undefined;
});

const options = {
localization: { locale },
};

return render(
<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).toHaveAttribute('data-locale', 'es');
});

it('should default to "en" when no locale is provided', () => {
// Mock the __internal_getOption to return undefined for localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return undefined;
}
return undefined;
});

const options = {};

render(
<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).toHaveAttribute('data-locale', 'en');
});

it('should handle different locale values', () => {
const locales = ['en', 'es', 'fr', 'de', 'it'];

locales.forEach(locale => {
const { unmount } = renderWithLocale(locale);

const elements = screen.getByTestId('stripe-elements');
expect(elements).toHaveAttribute('data-locale', locale);

unmount();
});
});

it('should handle undefined localization object', () => {
// Mock the __internal_getOption to return undefined for localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return undefined;
}
return undefined;
});

const options = {
localization: undefined,
};

render(
<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).toHaveAttribute('data-locale', 'en');
});

it('should work with full LocalizationResource structure like ClerkProvider', () => {
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return { locale: 'fr-FR' };
}
return undefined;
});

// This test simulates the actual ClerkProvider usage pattern:
// import { frFR } from '@clerk/localizations';
// <ClerkProvider localization={frFR}>
const options = {
localization: {
locale: 'fr-FR',
// This would normally contain all the translation strings from frFR
// but we only need the locale property for our implementation
},
};

render(
<OptionsContext.Provider value={options}>
<__experimental_PaymentElementProvider checkout={mockCheckout}>
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
</__experimental_PaymentElementProvider>
</OptionsContext.Provider>,
);

const elements = screen.getByTestId('stripe-elements');
// Should normalize 'fr-FR' to 'fr' for Stripe compatibility
expect(elements).toHaveAttribute('data-locale', 'fr');
});

it('should normalize full locale strings to 2-letter codes for Stripe', () => {
const testCases = [
{ input: 'en-US', expected: 'en' },
{ input: 'fr-FR', expected: 'fr' },
{ input: 'es-ES', expected: 'es' },
{ input: 'de-DE', expected: 'de' },
{ input: 'it-IT', expected: 'it' },
{ input: 'pt-BR', expected: 'pt' },
];

testCases.forEach(({ input, expected }) => {
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return { locale: input };
}
return undefined;
});

const options = {
localization: { locale: input },
};

const { unmount } = render(
<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).toHaveAttribute('data-locale', expected);

unmount();
});
});
Comment on lines +285 to +321
Copy link
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-BRpt). 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', () => {
const testCases = [
{ input: 'en-US', expected: 'en' },
{ input: 'fr-FR', expected: 'fr' },
{ input: 'es-ES', expected: 'es' },
{ input: 'de-DE', expected: 'de' },
{ input: 'it-IT', expected: 'it' },
{ input: 'pt-BR', expected: 'pt' },
];
testCases.forEach(({ input, expected }) => {
// Mock the __internal_getOption to return the expected localization
mockGetOption.mockImplementation(key => {
if (key === 'localization') {
return { locale: input };
}
return undefined;
});
const options = {
localization: { locale: input },
};
const { unmount } = render(
<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).toHaveAttribute('data-locale', expected);
unmount();
});
});
it('should normalize full locale strings to 2-letter codes for Stripe', () => {
- const testCases = [
- { input: 'en-US', expected: 'en' },
- { input: 'fr-FR', expected: 'fr' },
- { input: 'es-ES', expected: 'es' },
- { input: 'de-DE', expected: 'de' },
- { input: 'it-IT', expected: 'it' },
- { input: 'pt-BR', expected: 'pt' },
const testCases = [
// 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 };
}
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).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.

});
Loading
Loading