skip to content

In Metro, why can Button.js be loaded on iOS even though Button.ios.tsx exists next to it?

level: middleimportance: nice to knowfreq 16%

answer

  1. extension order beats platform suffix
  2. loop runs over sourceExts first
  3. per extension: .ios, .native, plain
  4. js comes before tsx
  5. resolver.platforms lists known names

basics

~10 s

Metro's resolver loops over sourceExts in order and, for each extension, tries .ios, then .native, then the plain name. With the default order js before tsx, Button.js matches before Button.ios.tsx is ever tried.

solid answer

~40 s

Platform-specific files only win within the same extension. For `import Button from './Button'` on iOS, Metro walks `resolver.sourceExts` in order (`js`, `jsx`, `json`, `ts`, `tsx` by default) and, for each extension, tries `Button.ios.<ext>`, then `Button.native.<ext>`, then `Button.<ext>`. So the candidates run `Button.ios.js`, `Button.native.js`, `Button.js`, and only later `Button.ios.tsx`. If a stale `Button.js` sits in the folder, perhaps left over from a migration to TypeScript, it wins on every platform and the `.ios.tsx` file is silently ignored. The fix is to delete or rename the leftover file, or keep every variant of a module in the same extension. `resolver.platforms` is a separate setting: the list of platform names Metro recognises, which out-of-tree platforms extend.

go deeper

for a junior

Know that Metro tries platform-suffixed and plain files for an import, and that the first existing candidate wins without warning.

for a middle

Walk the candidate list: for each sourceExt, .platform, .native, then plain, and show why js before tsx lets Button.js win.

for a senior

Spot the stale-file pattern from a symptom like ignored edits, and fix it without changing global resolution order.

for a principal

Set repository rules, one extension per module and no build output in source folders, that make this class of bug impossible.

## The surprising result A folder contains `Button.js` and `Button.ios.tsx`. On iOS, `import Button from './Button'` loads `Button.js`. Many developers expect the platform-specific file to win because it is more specific. In Metro it does not, because of the order in which the resolver builds candidate file names. ## How Metro builds the candidates For a path without an extension, Metro's resolver first tries the exact name, then loops over **`resolver.sourceExts` in order**. Inside that loop, for each extension, it tries three names: 1. `<name>.<platform>.<ext>`, for example `Button.ios.js` 2. `<name>.native.<ext>`, because Metro's resolver sets `preferNativePlatform` 3. `<name>.<ext>`, for example `Button.js` With the default `sourceExts` of `['js', 'jsx', 'json', 'ts', 'tsx']`, an iOS import of `./Button` tries: | Order | Candidate | |---|---| | 1-3 | `Button.ios.js`, `Button.native.js`, `Button.js` | | 4-6 | `Button.ios.jsx`, `Button.native.jsx`, `Button.jsx` | | 7-9 | `Button.ios.json`, `Button.native.json`, `Button.json` | | 10-12 | `Button.ios.ts`, `Button.native.ts`, `Button.ts` | | 13-15 | `Button.ios.tsx`, `Button.native.tsx`, `Button.tsx` | The first file that exists wins. `Button.js` is candidate 3, `Button.ios.tsx` is candidate 13. The extension is the outer loop; the platform suffix only breaks ties within one extension. ## Where this bites in practice - **Half-finished TypeScript migrations**: a compiled or forgotten `.js` file next to new `.ios.tsx` and `.android.tsx` files. - **Generated files**: a build step that emits `index.js` into a source folder that also holds `index.ios.ts`. - **Mixed libraries**: a package shipping `Thing.js` plus `Thing.ios.ts` overrides. The symptom is always the same: edits to the platform file have no effect, and there is no error, because resolution succeeded. ## What resolver.platforms does `resolver.platforms` is not the ordering rule. It is the list of platform names Metro recognises, used for example to read a platform from a file name and to build its module map. Metro's own default is `['ios', 'android', 'windows', 'web']`; `@react-native/metro-config` sets `['android', 'ios']`; `expo/metro-config` sets `['ios', 'android', 'tvos', 'macos']`. Out-of-tree platforms such as Windows or macOS add their name so their suffixed files are understood. The platform used for a given bundle comes from the bundle request itself. ## Two shortcuts that skip the loop - **An explicit extension**: `import Button from './Button.ios.tsx'` names the file exactly, so Metro tries that name first and finds it; the loop never runs. This forces one file on every platform, which is rarely what you want. - **A directory import**: `import Button from './Button'` where `Button` is a folder resolves through the folder's `package.json` entry or its `index` file, and the same per-extension loop then runs for `index`. ## Proving which file won When a platform file seems ignored, confirm before editing: put a temporary `console.log` at the top of each candidate file and reload, or temporarily wrap `resolver.resolveRequest` to log the resolved `filePath` for that specifier. Seeing `Button.js` in the log ends the investigation in seconds. ## Keeping resolution predictable - Keep every variant of one module in **one extension**: `Button.ios.tsx`, `Button.android.tsx` or `Button.tsx`. - Delete compiled output from source folders and exclude it with `resolver.blockList` if a tool keeps writing it. - When a change seems ignored, find which file Metro actually bundled before debugging the code inside it. - Remember that files reached through a package's `exports` get no platform or extension expansion at all.

  • If only Button.ios.tsx and Button.tsx exist, which file does Android load?
    `Button.tsx`. On Android the candidates for the `tsx` extension are `Button.android.tsx`, `Button.native.tsx` and `Button.tsx`; the first two do not exist, so the plain file wins. The iOS file is never considered because its suffix names another platform.
  • Does reordering sourceExts to put tsx first fix the stale Button.js problem?
    It changes which file wins, but it is the wrong fix: every import in the project, including those into third-party packages, now prefers `tsx` first, which can pick different files elsewhere. Deleting the stale file, or keeping each module in one extension, fixes the cause without changing global resolution.

A sorting office that files letters by postcode first and only then by name: a letter for the right person with a later postcode lands behind one with an earlier postcode, however well addressed it is.

saying these in an interview costs you the question

  • Believes a platform suffix always beats a plain file
  • Thinks resolver.platforms defines the file lookup order
  • Reorders sourceExts globally to fix one stale file
  • Assumes Metro reports an error when two candidates exist