From a9a2589cec22fc912c1e3738d8d510a433068252 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 29 Sep 2025 10:16:18 -0700 Subject: [PATCH 1/4] pass locale to stripe elements --- packages/clerk-js/sandbox/app.ts | 23 +- packages/clerk-js/src/core/clerk.ts | 8 +- .../src/react/__tests__/commerce.test.tsx | 321 ++++++++++++++++++ packages/shared/src/react/commerce.tsx | 17 + 4 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/react/__tests__/commerce.test.tsx diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 15d45afc722..f9793b0918a 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -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 => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 6e676e2d371..f9971e10926 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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; + const props = { ..._props, - options: this.#initOptions({ ...this.#options, ..._props.options }), + options: this.#initOptions(mergedOptions), }; + return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props)); }; diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/commerce.test.tsx new file mode 100644 index 00000000000..bd18ae14850 --- /dev/null +++ b/packages/shared/src/react/__tests__/commerce.test.tsx @@ -0,0 +1,321 @@ +import { render, screen } from '@testing-library/react'; +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 }) => ( +
+ {children} +
+ ), + PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => ( +
{fallback}
+ ), + 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: 'test@example.com', + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + }; + + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + 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'; + // + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + const elements = screen.getByTestId('stripe-elements'); + expect(elements).toHaveAttribute('data-locale', expected); + + unmount(); + }); + }); +}); diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx index 72fd978f4b1..0e7251cd2ba 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/commerce.tsx @@ -62,6 +62,21 @@ 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 (_) {} + + // Normalize locale to 2-letter language code for Stripe compatibility + const normalizedLocale = locale.split('-')[0]; + + return normalizedLocale; +}; + const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { const { organization } = useOrganization(); const { user } = useUser(); @@ -206,6 +221,7 @@ const PaymentElementProvider = ({ children, ...props }: PropsWithChildren { const { stripe, externalClientSecret, stripeAppearance } = usePaymentElementContext(); + const locale = useLocalization(); if (stripe && externalClientSecret) { return ( @@ -219,6 +235,7 @@ const PaymentElementInternalRoot = (props: PropsWithChildren) => { appearance: { variables: stripeAppearance, }, + locale: locale as any, }} > {props.children} From cca53a0bba848557094d82d2e277bfc275835a5b Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 29 Sep 2025 10:42:02 -0700 Subject: [PATCH 2/4] changeset --- .changeset/cold-bottles-watch.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/cold-bottles-watch.md diff --git a/.changeset/cold-bottles-watch.md b/.changeset/cold-bottles-watch.md new file mode 100644 index 00000000000..e57ff6acfba --- /dev/null +++ b/.changeset/cold-bottles-watch.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Pass current localization value to Stripe Elements From 9f503546790e0de9de7c329af06f29a7d0b1f0ca Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 29 Sep 2025 10:53:09 -0700 Subject: [PATCH 3/4] feedback --- packages/clerk-js/src/core/clerk.ts | 9 ++++++--- packages/shared/src/react/commerce.tsx | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f9971e10926..c014cdb39b0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2377,12 +2377,15 @@ export class Clerk implements ClerkInterface { // 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; + // 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(mergedOptions), + options: processedOptions, }; return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props)); diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx index 0e7251cd2ba..ca143a6aa74 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/commerce.tsx @@ -69,7 +69,9 @@ const useLocalization = () => { try { const localization = clerk.__internal_getOption('localization'); locale = localization?.locale || 'en'; - } catch (_) {} + } catch { + // ignore errors + } // Normalize locale to 2-letter language code for Stripe compatibility const normalizedLocale = locale.split('-')[0]; From a7f50d0c9d5f423b526a9eb105d4dd6d6ac207a8 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 29 Sep 2025 11:02:23 -0700 Subject: [PATCH 4/4] linter fix --- packages/shared/src/react/__tests__/commerce.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/commerce.test.tsx index bd18ae14850..9d090d8dff3 100644 --- a/packages/shared/src/react/__tests__/commerce.test.tsx +++ b/packages/shared/src/react/__tests__/commerce.test.tsx @@ -1,9 +1,10 @@ -import { render, screen } from '@testing-library/react'; 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'; -import { __experimental_PaymentElementProvider, __experimental_PaymentElement } from '../commerce'; // Mock the Stripe components jest.mock('../stripe-react', () => ({