diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 7cc2455120c..2590141cf79 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -32,6 +32,7 @@ { "path": "./dist/op-plans-page*.js", "maxSize": "3KB" }, { "path": "./dist/statement-page*.js", "maxSize": "5KB" }, { "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" }, - { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "79KB" } + { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "79KB" }, + { "path": "./dist/phone-country-data*.js", "maxSize": "10KB" } ] } diff --git a/packages/ui/rspack.config.js b/packages/ui/rspack.config.js index 3a7660b7211..99e167b07b9 100644 --- a/packages/ui/rspack.config.js +++ b/packages/ui/rspack.config.js @@ -98,6 +98,20 @@ const common = ({ mode, variant }) => { module.resource.includes('/components/SignUp') ), }, + /** + * Phone country code data is lazy-loaded via dynamic import + * and excluded from ui-common to keep it in its own async chunk. + */ + phoneCountryData: { + name: 'phone-country-data', + test: module => + !!( + module instanceof rspack.NormalModule && + module.resource && + module.resource.includes('/countryCodeData.') + ), + enforce: true, + }, common: { minChunks: 1, name: 'ui-common', @@ -107,7 +121,8 @@ const common = ({ mode, variant }) => { module instanceof rspack.NormalModule && module.resource && !module.resource.includes('/components') && - !module.resource.includes('node_modules') + !module.resource.includes('node_modules') && + !module.resource.includes('/countryCodeData.') ), }, defaultVendors: { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx index 1de4ab411a9..a86bb37c2c4 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx @@ -1,15 +1,20 @@ import { ClerkAPIResponseError, parseError } from '@clerk/shared/error'; import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { act, mockWebAuthn, render, screen } from '@/test/utils'; +import { loadCountryCodeData } from '@/ui/elements/PhoneInput/countryCodeDataLoader'; import { SignInFactorOne } from '../SignInFactorOne'; const { createFixtures } = bindCreateFixtures('SignIn'); +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('SignInFactorOne', () => { it('renders the component', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/utils.test.ts b/packages/ui/src/components/SignIn/__tests__/utils.test.ts index bde4ce8afcb..a15592dd4f8 100644 --- a/packages/ui/src/components/SignIn/__tests__/utils.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/utils.test.ts @@ -1,6 +1,7 @@ import type { SignInResource } from '@clerk/shared/types'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { loadCountryCodeData } from '@/ui/elements/PhoneInput/countryCodeDataLoader'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { @@ -10,6 +11,10 @@ import { getPreferredAlternativePhoneChannelForCombinedFlow, } from '../utils'; +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('determineStrategy(signIn, displayConfig)', () => { describe('with password as the preferred sign in strategy', () => { it('selects password if available', () => { diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpVerifyPhone.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpVerifyPhone.test.tsx index e7f3636d3c6..93bbbb59e8f 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpVerifyPhone.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpVerifyPhone.test.tsx @@ -1,13 +1,18 @@ import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; +import { loadCountryCodeData } from '@/ui/elements/PhoneInput/countryCodeDataLoader'; import { SignUpVerifyPhone } from '../SignUpVerifyPhone'; const { createFixtures } = bindCreateFixtures('SignUp'); +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('SignUpVerifyPhone', () => { it('renders the component', async () => { const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx index ff6e64e3a85..524b4361d97 100644 --- a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx @@ -6,14 +6,19 @@ import type { VerificationJSON, } from '@clerk/shared/types'; import { act, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; +import { loadCountryCodeData } from '@/ui/elements/PhoneInput/countryCodeDataLoader'; import { MfaSection } from '../MfaSection'; +beforeAll(async () => { + await loadCountryCodeData(); +}); + const { createFixtures } = bindCreateFixtures('UserProfile'); const initConfig = createFixtures.config(f => { diff --git a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx index 76614d4ddc1..a571892de66 100644 --- a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx @@ -1,14 +1,19 @@ import { act } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; +import { loadCountryCodeData } from '@/ui/elements/PhoneInput/countryCodeDataLoader'; import { PhoneSection } from '../PhoneSection'; const { createFixtures } = bindCreateFixtures('UserProfile'); +beforeAll(async () => { + await loadCountryCodeData(); +}); + const initConfig = createFixtures.config(f => { f.withPhoneNumber(); f.withUser({ email_addresses: ['test@clerk.com'] }); diff --git a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts index 9801aec8695..a3ec802365e 100644 --- a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts +++ b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts @@ -1,8 +1,13 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { loadCountryCodeData } from '../countryCodeDataLoader'; import { useFormattedPhoneNumber } from '../useFormattedPhoneNumber'; +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('useFormattedPhoneNumber', () => { afterEach(() => { // Empty the localStorage used within the hook diff --git a/packages/ui/src/elements/PhoneInput/countryCodeDataLoader.ts b/packages/ui/src/elements/PhoneInput/countryCodeDataLoader.ts new file mode 100644 index 00000000000..f9b8ab49d6d --- /dev/null +++ b/packages/ui/src/elements/PhoneInput/countryCodeDataLoader.ts @@ -0,0 +1,51 @@ +import type { + CodeToCountryIsoMapType, + CountryEntry, + CountryIso, + IsoToCountryMapType, +} from './countryCodeData'; + +// Hardcoded US fallback for use before data loads +export const US_FALLBACK_ENTRY: CountryEntry = { + name: 'United States' as CountryEntry['name'], + iso: 'us' as CountryIso, + code: '1' as CountryEntry['code'], + pattern: '(...) ...-....' as CountryEntry['pattern'], + priority: 100, +}; + +// Module-level cache +let isoToCountryMap: IsoToCountryMapType | undefined; +let codeToCountriesMap: CodeToCountryIsoMapType | undefined; +let subAreaCodeSets: { us: ReadonlySet; ca: ReadonlySet } | undefined; +let loadPromise: Promise | undefined; + +export function loadCountryCodeData(): Promise { + if (!loadPromise) { + loadPromise = import(/* webpackChunkName: "phone-country-data" */ './countryCodeData').then(mod => { + isoToCountryMap = mod.IsoToCountryMap; + codeToCountriesMap = mod.CodeToCountriesMap; + subAreaCodeSets = mod.SubAreaCodeSets; + }); + } + return loadPromise; +} + +export function isCountryCodeDataLoaded(): boolean { + return isoToCountryMap !== undefined; +} + +export function getIsoToCountryMap(): IsoToCountryMapType | undefined { + return isoToCountryMap; +} + +export function getCodeToCountriesMap(): CodeToCountryIsoMapType | undefined { + return codeToCountriesMap; +} + +export function getSubAreaCodeSets() { + return subAreaCodeSets; +} + +export type { CountryEntry, CountryIso, IsoToCountryMapType, CodeToCountryIsoMapType }; +export type { CountryName, DialingCode, PhonePattern } from './countryCodeData'; diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx index d945b88f8b2..1642a3b5e29 100644 --- a/packages/ui/src/elements/PhoneInput/index.tsx +++ b/packages/ui/src/elements/PhoneInput/index.tsx @@ -1,5 +1,5 @@ import { useClerk } from '@clerk/shared/react'; -import React, { forwardRef, memo, useEffect, useMemo, useRef } from 'react'; +import React, { forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react'; import { mergeRefs } from '@/ui/utils/mergeRefs'; import type { FeedbackType } from '@/ui/utils/useFormControl'; @@ -9,7 +9,7 @@ import { Check, ChevronUpDown } from '../../icons'; import { common, type PropsOfComponent } from '../../styledSystem'; import { Select, SelectButton, SelectOptionList } from '../Select'; import type { CountryEntry, CountryIso } from './countryCodeData'; -import { IsoToCountryMap } from './countryCodeData'; +import { getIsoToCountryMap, isCountryCodeDataLoaded, loadCountryCodeData } from './countryCodeDataLoader'; import { useFormattedPhoneNumber } from './useFormattedPhoneNumber'; const createSelectOption = (country: CountryEntry) => { @@ -21,7 +21,17 @@ const createSelectOption = (country: CountryEntry) => { }; }; -const countryOptions = [...IsoToCountryMap.values()].map(createSelectOption); +function useCountryCodeData() { + const [loaded, setLoaded] = useState(isCountryCodeDataLoaded); + + useEffect(() => { + if (!loaded) { + void loadCountryCodeData().then(() => setLoaded(true)); + } + }, [loaded]); + + return loaded; +} type PhoneInputProps = PropsOfComponent & { locationBasedCountryIso?: CountryIso }; @@ -34,6 +44,11 @@ const PhoneInputBase = forwardRef { + const map = getIsoToCountryMap(); + return map ? [...map.values()].map(createSelectOption) : []; + }, []); + const callOnChangeProp = () => { // Quick and dirty way to match this component's public API // with every other Input component, so we can use the same helpers @@ -43,7 +58,7 @@ const PhoneInputBase = forwardRef { return countryOptions.find(o => o.country.iso === iso) || countryOptions[0]; - }, [iso]); + }, [countryOptions, iso]); useEffect(callOnChangeProp, [numberWithCode]); @@ -248,6 +263,11 @@ const CountryCodeListItem = memo((props: CountryCodeListItemProps) => { export const PhoneInput = forwardRef( (props, ref) => { const { __internal_country } = useClerk(); + const dataLoaded = useCountryCodeData(); + + if (!dataLoaded) { + return null; + } return ( { if (!str) { return ''; } - const country = IsoToCountryMap.get(iso); + const country = getIsoToCountryMap()?.get(iso); return formatPhoneNumber(str, country?.pattern, country?.code); }; @@ -35,7 +35,7 @@ export const useFormattedPhoneNumber = (props: UseFormattedPhoneNumberProps) => if (!number) { return ''; } - const dialCode = IsoToCountryMap.get(iso)?.code || '1'; + const dialCode = getIsoToCountryMap()?.get(iso)?.code || '1'; return '+' + extractDigits(`${dialCode}${number}`); }, [iso, number]); diff --git a/packages/ui/src/utils/__tests__/formatSafeIdentifier.test.ts b/packages/ui/src/utils/__tests__/formatSafeIdentifier.test.ts index 4954ddea06c..e65fba4ebf9 100644 --- a/packages/ui/src/utils/__tests__/formatSafeIdentifier.test.ts +++ b/packages/ui/src/utils/__tests__/formatSafeIdentifier.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { loadCountryCodeData } from '../../elements/PhoneInput/countryCodeDataLoader'; import { formatSafeIdentifier } from '../formatSafeIdentifier'; +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('formatSafeIdentifier', () => { const cases = [ ['hello@example.com', 'hello@example.com'], diff --git a/packages/ui/src/utils/__tests__/phoneUtils.test.ts b/packages/ui/src/utils/__tests__/phoneUtils.test.ts index a2367827821..e9888fed84e 100644 --- a/packages/ui/src/utils/__tests__/phoneUtils.test.ts +++ b/packages/ui/src/utils/__tests__/phoneUtils.test.ts @@ -1,6 +1,7 @@ import type { PhoneCodeChannel } from '@clerk/shared/types'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { loadCountryCodeData } from '../../elements/PhoneInput/countryCodeDataLoader'; import { extractDigits, formatPhoneNumber, @@ -10,6 +11,10 @@ import { getPreferredPhoneCodeChannelByCountry, } from '../phoneUtils'; +beforeAll(async () => { + await loadCountryCodeData(); +}); + describe('phoneUtils', () => { describe('countryIsoToFlagEmoji(iso)', () => { it('handles undefined', () => { diff --git a/packages/ui/src/utils/phoneUtils.ts b/packages/ui/src/utils/phoneUtils.ts index 17e8ee4185c..747f3f6f3ee 100644 --- a/packages/ui/src/utils/phoneUtils.ts +++ b/packages/ui/src/utils/phoneUtils.ts @@ -1,7 +1,13 @@ import type { PhoneCodeChannel } from '@clerk/shared/types'; import type { CountryEntry, CountryIso } from '../elements/PhoneInput/countryCodeData'; -import { CodeToCountriesMap, IsoToCountryMap, SubAreaCodeSets } from '../elements/PhoneInput/countryCodeData'; +import { + getCodeToCountriesMap, + getIsoToCountryMap, + getSubAreaCodeSets, + loadCountryCodeData, + US_FALLBACK_ENTRY, +} from '../elements/PhoneInput/countryCodeDataLoader'; // offset between uppercase ascii and regional indicator symbols const OFFSET = 127397; @@ -19,6 +25,7 @@ export function getFlagEmojiFromCountryIso(iso: CountryIso, fallbackIso = 'us'): } export function getCountryIsoFromFormattedNumber(formattedNumber: string, fallbackIso = 'us'): string { + void loadCountryCodeData(); const number = extractDigits(formattedNumber); if (!number || number.length < 4) { return fallbackIso; @@ -65,16 +72,18 @@ export function extractDigits(formattedPhone: string): string { } function phoneNumberBelongsTo(iso: 'us' | 'ca', phoneWithCode: string) { - if (!iso || !IsoToCountryMap.get(iso) || !phoneWithCode) { + const isoMap = getIsoToCountryMap(); + const subAreaSets = getSubAreaCodeSets(); + if (!iso || !isoMap?.get(iso) || !phoneWithCode) { return false; } const code = phoneWithCode[0]; const subArea = phoneWithCode.substring(1, 4); return ( - code === IsoToCountryMap.get(iso)?.code && - phoneWithCode.length - 1 === maxDigitCountForPattern(IsoToCountryMap.get(iso)?.pattern || '') && - SubAreaCodeSets[iso].has(subArea) + code === isoMap.get(iso)?.code && + phoneWithCode.length - 1 === maxDigitCountForPattern(isoMap.get(iso)?.pattern || '') && + (subAreaSets?.[iso]?.has(subArea) ?? false) ); } @@ -92,16 +101,19 @@ function maxE164CompliantLength(countryCode?: string) { } export function parsePhoneString(str: string) { + void loadCountryCodeData(); const digits = extractDigits(str); const iso = getCountryIsoFromFormattedNumber(digits) as CountryIso; - const pattern = IsoToCountryMap.get(iso)?.pattern || ''; - const code = IsoToCountryMap.get(iso)?.code || ''; + const isoMap = getIsoToCountryMap(); + const pattern = isoMap?.get(iso)?.pattern || ''; + const code = isoMap?.get(iso)?.code || ''; const number = digits.slice(code.length); const formattedNumberWithCode = `+${code} ${formatPhoneNumber(number, pattern, code)}`; return { iso, pattern, code, number, formattedNumberWithCode }; } export function stringToFormattedPhoneString(str: string): string { + void loadCountryCodeData(); const parsed = parsePhoneString(str); return `+${parsed.code} ${formatPhoneNumber(parsed.number, parsed.pattern, parsed.code)}`; } @@ -111,20 +123,22 @@ export const byPriority = (a: CountryEntry, b: CountryEntry) => { }; export function getCountryFromPhoneString(phone: string): { number: string; country: CountryEntry } { + void loadCountryCodeData(); const phoneWithCode = extractDigits(phone); const matchingCountries = []; + const codeMap = getCodeToCountriesMap(); // Max country code length is 4. Try to match more specific codes first for (const i of [4, 3, 2, 1]) { const potentialCode = phoneWithCode.substring(0, i); - const countries = CodeToCountriesMap.get(potentialCode as any) || []; + const countries = codeMap?.get(potentialCode as any) || []; if (countries.length) { matchingCountries.push(...countries); } } - const fallbackCountry = IsoToCountryMap.get('us'); + const fallbackCountry = getIsoToCountryMap()?.get('us') ?? US_FALLBACK_ENTRY; const country: CountryEntry = matchingCountries.sort(byPriority)[0] || fallbackCountry; const number = phoneWithCode.slice(country?.code.length || 0);