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

Filter by extension

Filter by extension

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

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
11 changes: 10 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,19 @@ export class Clerk implements ClerkInterface {
// 2. clerk-js initializes propA with a default value
// 3. The customer update propB independently of propA and window.Clerk.updateProps is called
// 4. If we don't merge the new props with the current options, propA will be reset to undefined
const mergedOptions = { ...this.#options, ..._props.options };

// Process the merged options to ensure consistency between internal state and emitted props
const processedOptions = this.#initOptions(mergedOptions);

// Update the Clerk instance's internal options with processed data
this.#options = processedOptions;

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

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

Expand Down
322 changes: 322 additions & 0 deletions packages/shared/src/react/__tests__/commerce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
import '@testing-library/jest-dom';

import { render, screen } from '@testing-library/react';
import React from 'react';

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

// Mock the Stripe components
jest.mock('../stripe-react', () => ({
Elements: ({ children, options }: { children: React.ReactNode; options: any }) => (
<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