In Expo config plugins, what does createRunOncePlugin do, and why do libraries wrap their exported plugin with it?
answer
- a guard keyed by name
- _internal.pluginHistory
- legacy unversioned plugins
- version defaults to UNVERSIONED
- once per evaluation, not per file
basics
~20 screateRunOncePlugin wraps a plugin with withRunOnce: if its name is already in the config's _internal.pluginHistory it returns the config unchanged, otherwise it records the name and version and runs. Libraries use it so their plugin never applies twice.
solid answer
~40 s`createRunOncePlugin(plugin, name, version)` returns a plugin that calls `withRunOnce`. That checks `config._internal.pluginHistory[name]`; if an entry exists it returns the config untouched, otherwise it records `{ name, version }` and runs the wrapped plugin with its props. The version defaults to `UNVERSIONED` when omitted. It exists because Expo CLI can apply a legacy, unversioned plugin automatically when a package is installed but not listed in `plugins`; a library that ships its own versioned plugin, named after its package, wraps it so the two never both run, and a user's explicitly listed plugin takes precedence. It also guards against a plugin being reached twice through composition. It does **not** make mods idempotent across prebuild runs: the history lives in the evaluated config, not in the native files.
code
typescript · 13 linesimport { ConfigPlugin, createRunOncePlugin, withInfoPlist } from 'expo/config-plugins';
const pkg = require('my-widget-library/package.json');
type Props = { appGroup: string };
const withWidgetBridge: ConfigPlugin<Props> = (config, { appGroup }) =>
withInfoPlist(config, (config) => {
config.modResults.WidgetAppGroup = appGroup;
return config;
});
export default createRunOncePlugin(withWidgetBridge, pkg.name, pkg.version);go deeper
Recall that createRunOncePlugin stops the same plugin, identified by name, from running twice when the app config is evaluated.
Explain the pluginHistory lookup, the UNVERSIONED default, and the legacy auto-applied plugins it was built to deduplicate.
Distinguish run-once from idempotency: the history resets every evaluation, so mods must still be safe against files already edited by an earlier prebuild.
For a shared plugin library, standardize run-once keys on package names and ban UNVERSIONED entries, so duplicate application is impossible by construction.
## What the helper is `createRunOncePlugin` is exported from `expo/config-plugins`. Its signature is: ```ts createRunOncePlugin<T>(plugin: ConfigPlugin<T>, name: string, version?: string): ConfigPlugin<T> ``` It returns a new plugin that forwards to `withRunOnce`, which does exactly this: 1. Look up `config._internal.pluginHistory[name]`. 2. If an entry exists, **return the config unchanged**: the wrapped plugin does not run and its props are ignored. 3. Otherwise add `{ name, version }` to the history, with `version` set to `'UNVERSIONED'` when none was given, and run the wrapped plugin with the props it was called with. So the guard is **keyed by name only**. Whichever call reaches it first during config evaluation wins; later calls with the same name are no-ops even if they pass different props. ## Why it exists: legacy and versioned plugins Before libraries shipped their own config plugins, Expo bundled "unversioned" plugins for popular packages so that `eas build` produced the same native setup the old build service did. Those **legacy plugins** are applied automatically when a supported package is installed but not listed in the app config's `plugins` array; for example, a camera package gets its camera and microphone permission entries even if the developer never added its plugin. That creates a collision risk once the package ships its **own** plugin from `app.plugin.js`: both the bundled legacy plugin and the package's plugin could run and write the same entries twice, possibly with different values. The fix Expo adopted: - The library wraps its plugin with `createRunOncePlugin(plugin, pkg.name, pkg.version)`, using its package name as the key. - Expo's legacy fallback plugins are recorded in the same `pluginHistory` under the package name. - A plugin the user lists explicitly in `plugins` takes precedence over the automatic one, and the run-once guard makes the other a no-op. Keeping the name and version in sync with `package.json` is the convention in Expo's own docs. ## Reading the history `npx expo config --type prebuild` prints the evaluated config, including `_internal.pluginHistory`: | Entry | Meaning | |---|---| | `{ name: 'some-lib', version: '3.1.0' }` | The plugin came from the installed package, which passed its version | | `{ name: 'other-lib', version: 'UNVERSIONED' }` | No version was passed, typically a legacy fallback plugin bundled with Expo's tooling | Expo's advice is to aim for **no `UNVERSIONED` entries**, because a bundled legacy plugin may not match the native code of the library version actually installed. ## What it does not do The name suggests more than it delivers, and interviewers probe that gap: - It does **not** make a plugin's file edits idempotent. The history is part of the config object built on each evaluation; it is not stored in the `ios/` or `android/` folders. A second `npx expo prebuild` without `--clean` starts with an empty history and runs every plugin again against files that already hold last run's changes. - It does **not** merge props from two registrations; the second one is dropped silently. - It does **not** order plugins; it only decides whether a plugin runs. Idempotency across prebuild runs is the job of the mods themselves: set keys rather than append, check before adding, or use tagged generated blocks. ## Three different guards, three different scopes | Guard | Protects against | Scope | |---|---|---| | `createRunOncePlugin` | the same plugin being applied twice | one evaluation of the app config | | An idempotent mod (check before adding, `CodeGenerator.mergeContents`) | re-editing a file that already holds the change | every prebuild, with or without `--clean` | | `npx expo prebuild --clean` | any state left in the native folders | the whole native project, at the cost of regenerating it | A robust library plugin uses the first two together; the third is a project-level habit, not something a plugin can rely on. ## When to use it in your own code - **Publishing a library plugin**: wrap the default export, keyed by the package name and version. - **Composing internal plugins**: when two feature plugins both apply a shared base plugin, wrapping the base prevents it from running twice within one evaluation. - **App-local one-off plugins** listed once in `plugins` gain little from it.
- Two entries in an Expo project's plugins array reference the same run-once plugin with different props; which props apply?Only the first registration's. `withRunOnce` keys on the name alone, so when the second call finds the name in `_internal.pluginHistory` it returns the config untouched and its props are silently ignored. List the plugin once, or give it a single props object.
- What does an UNVERSIONED entry in an Expo project's _internal.pluginHistory signal?A plugin was recorded without a version, most often a legacy fallback plugin bundled with Expo's tooling for a package that does not ship its own. Expo recommends eliminating these, because the bundled plugin may not match the native code of the library version installed.
saying these in an interview costs you the question
- createRunOncePlugin makes a plugin's native file edits safe to rerun with prebuild.
- A second registration with different props merges into the first.
- Plugin history is saved in the native project between prebuild runs.
- The run-once key is the plugin function's identity, so names never collide.