-
Notifications
You must be signed in to change notification settings - Fork 402
feat(clerk-js): Pass locale to Stripe elements #6885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
a9a2589
pass locale to stripe elements
aeliox cca53a0
changeset
aeliox 9f50354
feedback
aeliox a7f50d0
linter fix
aeliox dcdb9d8
Merge branch 'main' into keiran/bill-1069
panteliselef 1322865
remove changes in props options
panteliselef fa215ee
clean up some of the tests
panteliselef e33b61f
remove as any
panteliselef 154ed01
replace jest with vite
panteliselef b87f162
Apply suggestion from @panteliselef
panteliselef 7917403
revert change
panteliselef 0333fce
Merge branch 'main' into keiran/bill-1069
panteliselef File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@clerk/clerk-js': patch | ||
| '@clerk/shared': patch | ||
| --- | ||
|
|
||
| Propagate locale from ClerkProvider to PaymentElement |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import React from 'react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce'; | ||
| import { OptionsContext } from '../contexts'; | ||
|
|
||
| // Mock the Stripe components | ||
| vi.mock('../stripe-react', () => ({ | ||
| Elements: ({ children, options }: { children: React.ReactNode; options: any }) => ( | ||
| <div | ||
| data-testid='stripe-elements' | ||
| data-locale={options.locale} | ||
| > | ||
| {children} | ||
| </div> | ||
| ), | ||
| PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => <div>{fallback}</div>, | ||
| useElements: () => null, | ||
| useStripe: () => null, | ||
| })); | ||
|
|
||
| // Mock the hooks | ||
| const mockGetOption = vi.fn(); | ||
| vi.mock('../hooks/useClerk', () => ({ | ||
| useClerk: () => ({ | ||
| __internal_loadStripeJs: vi.fn().mockResolvedValue(() => Promise.resolve({})), | ||
| __internal_getOption: mockGetOption, | ||
| __unstable__environment: { | ||
| commerceSettings: { | ||
| billing: { | ||
| stripePublishableKey: 'pk_test_123', | ||
| }, | ||
| }, | ||
| displayConfig: { | ||
| userProfileUrl: 'https://example.com/profile', | ||
| organizationProfileUrl: 'https://example.com/org-profile', | ||
| }, | ||
| }, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../hooks/useUser', () => ({ | ||
| useUser: () => ({ | ||
| user: { | ||
| id: 'user_123', | ||
| initializePaymentSource: vi.fn().mockResolvedValue({ | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }), | ||
| }, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../hooks/useOrganization', () => ({ | ||
| useOrganization: () => ({ | ||
| organization: null, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('swr', () => ({ | ||
| __esModule: true, | ||
| default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }), | ||
| })); | ||
|
|
||
| vi.mock('swr/mutation', () => ({ | ||
| __esModule: true, | ||
| default: () => ({ | ||
| data: { | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }, | ||
| trigger: vi.fn().mockResolvedValue({ | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }), | ||
| }), | ||
| })); | ||
|
|
||
| describe('PaymentElement Localization', () => { | ||
| const mockCheckout = { | ||
| id: 'checkout_123', | ||
| needsPaymentMethod: true, | ||
| plan: { | ||
| id: 'plan_123', | ||
| name: 'Test Plan', | ||
| description: 'Test plan description', | ||
| fee: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| annualFee: { amount: 10000, amountFormatted: '$100.00', currency: 'usd', currencySymbol: '$' }, | ||
| annualMonthlyFee: { amount: 833, amountFormatted: '$8.33', currency: 'usd', currencySymbol: '$' }, | ||
| currency: 'usd', | ||
| interval: 'month' as const, | ||
| intervalCount: 1, | ||
| maxAllowedInstances: 1, | ||
| trialDays: 0, | ||
| isAddon: false, | ||
| isPopular: false, | ||
| isPerSeat: false, | ||
| isUsageBased: false, | ||
| isFree: false, | ||
| isLegacy: false, | ||
| isDefault: false, | ||
| isRecurring: true, | ||
| hasBaseFee: true, | ||
| forPayerType: 'user' as const, | ||
| publiclyVisible: true, | ||
| slug: 'test-plan', | ||
| avatarUrl: '', | ||
| freeTrialDays: 0, | ||
| freeTrialEnabled: false, | ||
| pathRoot: '/', | ||
| reload: vi.fn(), | ||
| features: [], | ||
| limits: {}, | ||
| metadata: {}, | ||
| }, | ||
| totals: { | ||
| subtotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| }, | ||
| status: 'needs_confirmation' as const, | ||
| error: null, | ||
| fetchStatus: 'idle' as const, | ||
| confirm: vi.fn(), | ||
| start: vi.fn(), | ||
| clear: vi.fn(), | ||
| finalize: vi.fn(), | ||
| getState: vi.fn(), | ||
| isConfirming: false, | ||
| isStarting: false, | ||
| planPeriod: 'month' as const, | ||
| externalClientSecret: 'seti_123', | ||
| externalGatewayId: 'acct_123', | ||
| isImmediatePlanChange: false, | ||
| paymentMethodOrder: ['card'], | ||
| freeTrialEndsAt: null, | ||
| payer: { | ||
| id: 'payer_123', | ||
| createdAt: new Date('2023-01-01'), | ||
| updatedAt: new Date('2023-01-01'), | ||
| imageUrl: null, | ||
| userId: 'user_123', | ||
| email: 'test@example.com', | ||
| firstName: 'Test', | ||
| lastName: 'User', | ||
| organizationId: undefined, | ||
| organizationName: undefined, | ||
| pathRoot: '/', | ||
| reload: vi.fn(), | ||
| }, | ||
| }; | ||
|
|
||
| const renderWithLocale = (locale: string) => { | ||
| // Mock the __internal_getOption to return the expected localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return { locale }; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = { | ||
| localization: { locale }, | ||
| }; | ||
|
|
||
| return render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
| }; | ||
|
|
||
| it('should pass the correct locale to Stripe Elements', () => { | ||
| renderWithLocale('es'); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe('es'); | ||
| }); | ||
|
|
||
| it('should default to "en" when no locale is provided', () => { | ||
| // Mock the __internal_getOption to return undefined for localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return undefined; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = {}; | ||
|
|
||
| render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe('en'); | ||
| }); | ||
|
|
||
| it('should normalize full locale strings to 2-letter codes for Stripe', () => { | ||
| const testCases = [ | ||
| { input: 'en', expected: 'en' }, | ||
| { input: 'en-US', expected: 'en' }, | ||
| { input: 'fr-FR', expected: 'fr' }, | ||
| { input: 'es-ES', expected: 'es' }, | ||
| { input: 'de-DE', expected: 'de' }, | ||
| { input: 'it-IT', expected: 'it' }, | ||
| { input: 'pt-BR', expected: 'pt' }, | ||
| ]; | ||
|
|
||
| testCases.forEach(({ input, expected }) => { | ||
| // Mock the __internal_getOption to return the expected localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return { locale: input }; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = { | ||
| localization: { locale: input }, | ||
| }; | ||
|
|
||
| const { unmount } = render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe(expected); | ||
|
|
||
| unmount(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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:
en-GB,fr-CA,es-419,pt-BRzh-Hans,zh-Hant-TW,zh-Hant-HKCollapsing
pt-BR→ptorzh-Hant-TW→zhcan 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:
💡 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:
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.