From f7fb78c36c2b32bc38ead795771805bcc1f603ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 08:22:26 +0000 Subject: [PATCH] perf: cut startup work, unblock login, and stop polling every few seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate causes behind the slow launch, addressed in the order they cost the user time. Cold start. Sentry's app.start.cold spans put ~94% of a 4.7s cold start in "JS Bundle Execution Before React Root" — no network involved, just Metro evaluating every module in the graph before React renders, because it hoists every require to the top of its module. Metro now defers each require to its first point of use. Modules imported for their side effects rather than their exports are pinned in nonInlinedRequires: the polyfill chain index.js deliberately orders, plus NativeWind's CSS interop. experimentalImportSupport is restated because returning this object replaces Expo's own getTransformOptions instead of merging. Sentry's profiler and mobile session replay both ran on half of all sessions, alongside Amplitude's replay plugin, on hardware where every session I sampled came from a low-end Android device. Both drop to a tenth; replays on error are untouched. Login. handleLogin awaited six things before navigating. Two of them do not need to be there. safeAA existed only to read an address the login response already returned, at the cost of a Turnkey round trip plus the RPC calls toSafeSmartAccount makes — the smart-account client itself is only needed to sign, and the flows that sign build it themselves. It is now the fallback for an account with no address on file, which is the same condition that already triggered the sync. checkBalance and fetchPoints are started rather than awaited; both re-run in usePostSignupInit once the app is up. Polling. An idle home screen was issuing ~370 requests a minute: token balances every 5s (19 parallel calls plus the token processing each time), vault balances every 3s, card balance and native prices every 5s. useActivitySSE already invalidates both tokenBalances and vault on a balance event, so these are a fallback and now run like one. This is also the most plausible source of the 210k upstream-connection 503s in Sentry, where the app is on both ends of the loop. Also: Blockscout had no timeout on either call path, so a stalled explorer held the balance skeleton open indefinitely — both now carry the same deadline as Alchemy. Blockscout and LI.FI moved off the global axios instance, which attaches the user's access token to every request on native. The interceptor's missing-token warning now fires only for calls to our own services, instead of once per public request. useTotalVaultBalance awaited each vault's chains in turn; one Promise.all covers all of them. publicClient built a fresh viem client per call, seven times per token-balance run, on a timer. Corrects a wrong note in lib/wagmi.ts: multicall batching was never off. @wagmi/core's createConfig defaults batch to { multicall: true }, so the commented-out line was a no-op and uncommenting it would change nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UNaLEk8QE73jaaW6pbRef4 --- app/_layout.tsx | 15 ++++++-- hooks/useBalances.ts | 11 ++++-- hooks/useCardDetails.ts | 6 ++- hooks/useTotalSavingsUSD.ts | 11 ++++-- hooks/useUser.ts | 74 +++++++++++++++++++++++++------------ hooks/useVault.ts | 28 +++++++++----- lib/api.ts | 34 ++++++++++++++--- lib/data-source.ts | 44 ++++++++++++++++++++-- lib/wagmi.ts | 52 ++++++++++++++++++++------ metro.config.js | 51 +++++++++++++++++++++++++ 10 files changed, 260 insertions(+), 66 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 3adb6f0b2..76b2f31e9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -68,8 +68,15 @@ Sentry.init({ sendDefaultPii: true, // Performance Monitoring - configured upfront, integrations added later + // + // Profiling and session replay are the two settings here that cost something + // on every frame rather than only when an event is sent, and half of all + // sessions were carrying both at once (plus Amplitude's own replay plugin, see + // `lib/analytics.ts`). On the low-end Android hardware most of our sessions + // run on, that competes with the app itself. A tenth of sessions is still a + // large sample at our volume. tracesSampleRate: 0.5, - profilesSampleRate: 0.5, + profilesSampleRate: 0.1, // Release Health enableAutoSessionTracking: true, @@ -103,8 +110,10 @@ Sentry.init({ return event; }, - // Configure Session Replay - rates set upfront, integration added later - replaysSessionSampleRate: 0.5, + // Configure Session Replay - rates set upfront, integration added later. + // See the profiling note above for why this is no longer half of all sessions; + // replays on error are unaffected, so nothing is lost from an incident. + replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1, // Integrations diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index ae2b60923..cd0c047ce 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -640,14 +640,19 @@ export const useBalances = (): BalanceData => { queryFn: () => fetchTokenBalances(user?.safeAddress!), enabled: !!user?.safeAddress, // TanStack Query handles all the manual logic: - staleTime: 5_000, + staleTime: 30_000, gcTime: 5 * 60 * 1000, // 5 minutes - data stays in cache for 5 minutes when unused retry: 3, // retry up to 3 times on failure retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff refetchOnWindowFocus: true, // refetch when user returns to tab refetchOnReconnect: true, // refetch when network reconnects - // SSE handles real-time updates; polling is fallback for missed events or SSE failure - refetchInterval: 5_000, + // SSE handles real-time updates; polling is fallback for missed events or SSE + // failure. One run of this query is 19 parallel requests plus the token + // processing below, so at the previous 5s it was ~228 requests a minute per + // idle client — and the CPU cost landed on exactly the low-end devices that + // could least afford it. `useActivitySSE` invalidates `tokenBalances` + // directly, so a real balance change still lands immediately. + refetchInterval: 60_000, refetchIntervalInBackground: false, // Don't refetch when app is backgrounded (saves battery) }); diff --git a/hooks/useCardDetails.ts b/hooks/useCardDetails.ts index 37f33081a..09aa230aa 100644 --- a/hooks/useCardDetails.ts +++ b/hooks/useCardDetails.ts @@ -30,7 +30,11 @@ export const useCardDetails = () => { queryFn: () => withRefreshToken(() => getCardBalance()), enabled: !isDummyUser && provider === CardProvider.RAIN && !!detailsQuery.data, retry: false, - refetchInterval: 5000, + // Rain's spending power, polled as a background freshness check rather than + // a live feed — this query is mounted on the home screen too, so at 5s it + // was 12 requests a minute from every session regardless of whether the card + // was on screen. + refetchInterval: 20_000, }); const mergedData = useMemo((): CardDetailsResponseDto | undefined => { diff --git a/hooks/useTotalSavingsUSD.ts b/hooks/useTotalSavingsUSD.ts index b801fa508..cf1c4ce4a 100644 --- a/hooks/useTotalSavingsUSD.ts +++ b/hooks/useTotalSavingsUSD.ts @@ -52,19 +52,22 @@ export const useTotalSavingsUSD = (): { data: number | undefined; isLoading: boo const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0; const hasEthBalance = !!ethVault && (balanceEth ?? 0) > 0; + // Native token prices, used only to value a savings balance in USD. A minute + // of staleness is well inside the rounding of the figure being displayed; at + // 5s these were two more requests every five seconds from every session. const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({ queryKey: ['fusePriceUsd'], queryFn: fetchFusePrice, enabled: hasFuseBalance, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: 60_000, + refetchInterval: 60_000, }); const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useQuery({ queryKey: ['ethPriceUsd'], queryFn: fetchEthPrice, enabled: hasEthBalance, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: 60_000, + refetchInterval: 60_000, }); const isLoading = diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 6106f2e0f..8bb7d3c4e 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -292,17 +292,35 @@ const useUser = (): UseUserReturn => { // Step 3: Authenticate with our backend using the signed request const user = await login(stamp); - const smartAccountClient = await safeAA(mainnet, user.subOrganizationId, user.walletAddress); + // The login response already carries the account's safe address, so the + // usual path no longer derives it locally. Deriving it means a Turnkey + // `createAccount` round trip plus the RPC calls `toSafeSmartAccount` makes + // — all of it in front of the user, who is watching a spinner, to arrive + // at an address the server just sent us. The smart-account *client* is + // only needed to sign a transaction, and that is built on demand by the + // flows that actually send one. + // + // The fallback below covers the one case that genuinely has no address on + // file, which is the same condition that already triggered the sync. + let safeAddress = user.safeAddress; + + if (!safeAddress) { + const smartAccountClient = await safeAA( + mainnet, + user.subOrganizationId, + user.walletAddress, + ); + // Bound to a const so the closure below captures the derived address + // itself rather than the reassignable outer binding. + const derivedSafeAddress = smartAccountClient.account.address; + safeAddress = derivedSafeAddress; - if (!user.safeAddress || user.safeAddress === '') { console.warn('[useUser] updating safe address on login (missing on user)', { userId: user._id, - safeAddress: smartAccountClient.account.address, + safeAddress: derivedSafeAddress, }); - const resp = await withRefreshToken(() => - updateSafeAddress(smartAccountClient.account.address), - ); + const resp = await withRefreshToken(() => updateSafeAddress(derivedSafeAddress)); if (!resp) { const error = new Error('Error updating safe address on login'); @@ -313,7 +331,7 @@ const useUser = (): UseUserReturn => { }, user: { id: user._id, - address: smartAccountClient.account.address, + address: derivedSafeAddress, }, }); } @@ -323,7 +341,7 @@ const useUser = (): UseUserReturn => { } const selectedUser: User = { - safeAddress: smartAccountClient.account.address, + safeAddress, walletAddress: user.walletAddress, username: user.username, userId: user._id, @@ -359,12 +377,16 @@ const useUser = (): UseUserReturn => { }); } - await checkBalance(selectedUser); + // Started, not awaited. This is a subgraph query whose only job is to set + // `isDeposited` on the stored user; the home screen derives the same flag + // from its own queries, and `usePostSignupInit` runs this again on mount. + // Blocking navigation on it just moved the wait in front of the user. + void checkBalance(selectedUser); // Identify user in analytics with full attribution context trackIdentity(user.userId, { username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, email: user.email, has_referral_code: !!user.referralCode, login_method: 'passkey', @@ -374,25 +396,29 @@ const useUser = (): UseUserReturn => { attribution_channel: getAttributionChannel(attributionData), }); - // Fetch points after successful login - try { - const { fetchPoints } = usePointsStore.getState(); - await fetchPoints(); - } catch (error) { - console.warn('Failed to fetch points:', error); - Sentry.captureException(new Error('Error fetching points'), { - extra: { - error, - }, + // Fetch points after successful login — in the background. Points are not + // on the first screen the user lands on, and the summary behind this is + // one of the slower endpoints we have, so awaiting it here meant the + // login button stayed busy for a number nothing was waiting to render. + // `usePostSignupInit` also fetches these once the app is up. + usePointsStore + .getState() + .fetchPoints() + .catch(error => { + console.warn('Failed to fetch points:', error); + Sentry.captureException(new Error('Error fetching points'), { + extra: { + error, + }, + }); + // Don't fail login if points fetch fails }); - // Don't fail login if points fetch fails - } setLoginInfo({ status: Status.SUCCESS }); track(TRACKING_EVENTS.LOGGED_IN, { user_id: user.userId, username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, has_email: !!user.email, is_deposited: !!user.isDeposited, device_id: deviceId, @@ -403,7 +429,7 @@ const useUser = (): UseUserReturn => { // Update user properties on login with attribution trackIdentity(user.userId, { username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, has_email: !!user.email, is_deposited: !!user.isDeposited, last_login_date: new Date().toISOString(), diff --git a/hooks/useVault.ts b/hooks/useVault.ts index c45623a47..e1dbea8ea 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -10,10 +10,17 @@ import { ADDRESSES } from '@/lib/config'; import { Vault } from '@/lib/types'; import { config } from '@/lib/wagmi'; -// Cache configuration for vault queries -const VAULT_STALE_TIME = secondsToMilliseconds(3); // Consider data fresh for 3 seconds +// Cache configuration for vault queries. +// +// These are a *fallback*, not the live path: `useActivitySSE` invalidates the +// `vault` keys as soon as a balance event arrives (debounced 200ms for +// deposits), so a balance still updates in near real time without polling. +// Polling every 3s only mattered when the stream was down — and at that rate +// each active client was issuing ~100 contract reads a minute, on every screen +// that watches a vault, forever. +const VAULT_STALE_TIME = secondsToMilliseconds(15); // Consider data fresh for 15 seconds const VAULT_GC_TIME = secondsToMilliseconds(300); // Keep in cache for 5 minutes -const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(3); // Poll every 3 seconds for near-realtime updates +const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(30); // Fallback poll; SSE drives live updates export const VAULT = 'vault'; @@ -141,16 +148,17 @@ export const useTotalVaultBalance = (safeAddress: Address) => { return useQuery({ queryKey: [VAULT, 'balanceTotal', safeAddress], queryFn: async () => { - let total = 0; - for (const vault of VAULTS) { - const balances = await Promise.all( + // One round trip for every vault on every chain, rather than a round per + // vault: the reads are independent, so awaiting each vault's group in turn + // made this as slow as the sum of the chains instead of the slowest one. + const balances = await Promise.all( + VAULTS.flatMap(vault => (vault.vaults ?? []).map(v => fetchVaultBalance(queryClient, safeAddress, v.chainId, v.address, vault.decimals), ), - ); - total += balances.reduce((acc, curr) => acc + curr, 0); - } - return total; + ), + ); + return balances.reduce((acc, curr) => acc + curr, 0); }, enabled: !!safeAddress, staleTime: VAULT_STALE_TIME, diff --git a/lib/api.ts b/lib/api.ts index da0d5b13f..310cec472 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -169,6 +169,17 @@ const getRefreshToken = (): string | null => { return null; }; +/** The Solid services a request can legitimately expect to be authenticated. */ +const SOLID_API_BASE_URLS = [ + EXPO_PUBLIC_FLASH_API_BASE_URL, + EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL, + EXPO_PUBLIC_FLASH_REWARDS_API_BASE_URL, + EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL, +].filter(Boolean); + +const isSolidApiUrl = (url: string | undefined): boolean => + !!url && SOLID_API_BASE_URLS.some(base => url.startsWith(base)); + // Set up axios interceptor to add headers to all axios requests axios.interceptors.request.use(config => { const platformHeaders = getPlatformHeaders(); @@ -183,7 +194,12 @@ axios.interceptors.request.use(config => { if (jwtToken) { config.headers['Authorization'] = `Bearer ${jwtToken}`; - } else { + } else if (isSolidApiUrl(config.url)) { + // Only a call to one of our own services is *supposed* to carry a token, + // so only that case is worth reporting. Reporting every tokenless request + // meant public endpoints and third-party hosts — which have no business + // holding a Solid access token — raised a warning apiece, burying the + // genuine auth failures this is here to catch. console.error('No JWT token found'); Sentry.captureMessage('No JWT token found', { level: 'warning', @@ -1970,7 +1986,9 @@ export const getLifiQuote = async ({ toToken = 'USDC', order = LifiOrder.FASTEST, }: GetLifiQuoteParams): Promise => { - const response = await axios.get(`${EXPO_PUBLIC_LIFI_API_URL}/quote`, { + // externalAxios: LI.FI is a third party and must not receive the user's Solid + // access token, which the global instance's interceptor attaches on native. + const response = await externalAxios.get(`${EXPO_PUBLIC_LIFI_API_URL}/quote`, { params: { fromAddress, fromChain, @@ -1987,11 +2005,15 @@ export const getLifiQuote = async ({ }; export const checkBridgeStatus = async (bridgeTxHash: string): Promise => { - const response = await axios.get(`${EXPO_PUBLIC_LIFI_API_URL}/status`, { - params: { - txHash: bridgeTxHash, + // externalAxios: third-party host, see getLifiQuote above. + const response = await externalAxios.get( + `${EXPO_PUBLIC_LIFI_API_URL}/status`, + { + params: { + txHash: bridgeTxHash, + }, }, - }); + ); return response?.data; }; diff --git a/lib/data-source.ts b/lib/data-source.ts index c7f5e61c9..e20f56005 100644 --- a/lib/data-source.ts +++ b/lib/data-source.ts @@ -4,6 +4,7 @@ import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains'; import { isAlchemyChain } from '@/constants/alchemy'; import { explorerUrls } from '@/constants/explorers'; import { fetchAlchemyTokenBalances, fetchAlchemyTokenTransfers } from '@/lib/alchemy'; +import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; import { BlockscoutTransactions } from '@/lib/types'; import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; @@ -13,6 +14,39 @@ import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; * Fuse (122) is always Blockscout (not supported by Alchemy). */ +/** + * Deadline for a Blockscout request. + * + * These calls gate the wallet balance skeleton, and nothing times out a bare + * `fetch` on its own — so an explorer that accepts the connection and then + * stops responding used to hold the home screen in its loading state + * indefinitely rather than failing over or rendering what it has. Matches the + * timeout already applied to Alchemy. + */ +const BLOCKSCOUT_REQUEST_TIMEOUT_MS = 10_000; + +/** + * A dedicated axios instance for Blockscout. + * + * The global axios in `lib/api.ts` carries a request interceptor that attaches + * the user's Solid backend JWT to every request on iOS/Android. Blockscout is a + * third-party explorer that has no use for that token and should never receive + * it — the same reasoning behind `externalAxios` and `alchemyAxios`. Declared + * here rather than imported from `lib/api.ts` because that module imports this + * one, so sharing the instance would close an import cycle. + * + * `axios.create()` starts with no interceptors, so this sends neither the JWT + * nor the platform headers, and carries its own deadline. + */ +const blockscoutAxios = axios.create({ timeout: BLOCKSCOUT_REQUEST_TIMEOUT_MS }); + +/** + * A Blockscout instance that is up but slow should not cost the user the + * balances every *other* chain already returned. `fetchTokenBalances` collects + * these with `Promise.allSettled`, so a rejection here degrades one chain; + * hanging degrades the whole screen. + */ + const BLOCKSCOUT_URLS: Record = { [mainnet.id]: 'https://eth.blockscout.com', [base.id]: 'https://base.blockscout.com', @@ -29,9 +63,11 @@ const fetchBlockscoutTokenBalances = async ( ): Promise => { const url = blockscoutUrlForChain(chainId); if (!url) return []; - const response = await fetch(`${url}/api/v2/addresses/${address}/token-balances`, { - headers: { accept: 'application/json' }, - }); + const response = await fetchWithTimeout( + `${url}/api/v2/addresses/${address}/token-balances`, + { headers: { accept: 'application/json' } }, + BLOCKSCOUT_REQUEST_TIMEOUT_MS, + ); if (!response.ok) { throw new Error(`Blockscout token-balances ${response.status} for chain ${chainId}`); } @@ -55,7 +91,7 @@ const fetchBlockscoutTokenTransfers = async ({ const params: string[] = ['type=ERC-20']; if (filter) params.push(`filter=${filter}`); if (token) params.push(`token=${token}`); - const response = await axios.get( + const response = await blockscoutAxios.get( `${url}/api/v2/addresses/${address}/token-transfers?${params.join('&')}`, ); return response.data; diff --git a/lib/wagmi.ts b/lib/wagmi.ts index ba909cfb0..bf54c3448 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -2,15 +2,7 @@ import { Platform } from 'react-native'; import { Chain, createPublicClient } from 'viem'; import { createConfig, http } from 'wagmi'; import { getWalletClient } from 'wagmi/actions'; -import { - arbitrum, - base, - baseSepolia, - bsc, - fuse, - mainnet, - polygon, -} from 'wagmi/chains'; +import { arbitrum, base, baseSepolia, bsc, fuse, mainnet, polygon } from 'wagmi/chains'; import { EXPO_PUBLIC_ALCHEMY_API_KEY } from './config'; @@ -42,12 +34,40 @@ const transports: Record> = { [bsc.id]: http(rpcUrls[bsc.id]), }; -export const publicClient = (chainId: number) => +/** + * Public clients, one per chain, built on first use and then reused. + * + * This used to construct a fresh client (and a fresh transport with it) on every + * call. `fetchTokenBalances` alone calls it seven times per run, and that run + * repeats on a timer — so the app was rebuilding the same object graph + * continuously, and each throwaway transport also threw away whatever request + * de-duplication it had accumulated. + * + * Keyed by chain id; the client is stateless with respect to the caller, so + * sharing one is safe and is what viem expects. + */ +const createChainClient = (chainId: number) => createPublicClient({ chain: chains.find(chain => chain.id === chainId), transport: http(rpcUrls[chainId]), }); +// Typed off `createChainClient` rather than `createPublicClient` so the cache +// carries the precise client type viem infers from these arguments. Naming the +// generic-erased `ReturnType` here would widen it, +// and callers that pass the client on — `toSafeSmartAccount`, for one — reject +// the widened type. +const publicClients = new Map>(); + +export const publicClient = (chainId: number) => { + const cached = publicClients.get(chainId); + if (cached) return cached; + + const client = createChainClient(chainId); + publicClients.set(chainId, client); + return client; +}; + export const getWallet = (chainId: number) => { return getWalletClient(config, { chainId }); }; @@ -55,7 +75,17 @@ export const getWallet = (chainId: number) => { export const config = createConfig({ chains, transports, - // batch: { multicall: true }, + // Multicall batching is deliberately not set here: `createConfig` already + // defaults `batch` to `{ multicall: true }` (see `@wagmi/core`'s + // `createConfig`, `batch: properties.batch ?? { multicall: true }`), so every + // `eth_call` routed through this config — the vault `balanceOf` reads, the + // accountant `getRate` reads — is already aggregated per chain. Passing it + // explicitly would change nothing. + // + // Note this only covers calls made *through this config*. The standalone + // clients from `publicClient()` above are plain viem clients and batch + // nothing; that is fine for what they do (`eth_getBalance` is not + // multicall-able, and their `eth_call`s are one per chain). }); export const fuseConfig = createConfig({ diff --git a/metro.config.js b/metro.config.js index d2510a69e..75dccb953 100644 --- a/metro.config.js +++ b/metro.config.js @@ -83,5 +83,56 @@ config.transformer.minifierConfig = { }, }; +/** + * Defer module evaluation until a module is first used. + * + * Sentry's `app.start.cold` spans put ~94% of a cold start in "JS Bundle + * Execution Before React Root" — the app is not waiting on the network at that + * point, it is evaluating every module in the graph, because without this Metro + * hoists every `require` to the top of its module. That means thirdweb, viem, + * wagmi, permissionless, the Turnkey and Sumsub SDKs, Intercom, Lottie and the + * rest are all constructed before the first screen renders, whether or not the + * user will ever reach a screen that needs them. + * + * `inlineRequires` rewrites each `require` to its first point of use, so a + * module is evaluated when something actually reads from it. + * + * The trade-off is import-time side effects: a module imported purely so its + * top-level code runs, and whose exports are never referenced, would never be + * evaluated. `nonInlinedRequires` pins those. The list below is Metro's default + * set plus this app's own ordering-sensitive imports — see `index.js`, which + * deliberately loads Reanimated, then the crypto polyfill, then the thirdweb + * adapter, before Expo Router. + */ +config.transformer.getTransformOptions = async () => ({ + transform: { + // Matches the Expo default (see @expo/metro-config's ExpoMetroConfig); + // repeated here because returning this object replaces Expo's own + // getTransformOptions rather than merging with it. + experimentalImportSupport: true, + inlineRequires: true, + nonInlinedRequires: [ + // Metro's defaults — the module registry and React's runtime must be + // initialised eagerly. + '@react-native/js-polyfills', + 'React', + 'react', + 'react-compiler-runtime', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', + 'react-native', + // Ordering-sensitive polyfills from index.js. Each is imported for the + // side effect of installing globals that later modules assume are + // present, so none may be deferred to first use. + 'react-native-reanimated', + 'react-native-get-random-values', + 'react-native-quick-crypto', + '@thirdweb-dev/react-native-adapter', + // Installs the global CSS interop that NativeWind's styling depends on. + 'react-native-css-interop', + ], + }, +}); + // inlineRem: NativeWind defaults to 14px on native. Set to 16 to match web rem sizing. module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });