skip to content

An Expo config plugin uses withDangerousMod to regex-insert a line into ios/Podfile, and each prebuild without --clean adds another copy; why, and how do you fix it?

level: seniorimportance: nice to knowfreq 20%

answer

  1. prebuild layers onto existing files
  2. dangerous mod: raw file, no parser
  3. regex matches its own output
  4. tagged generated block, hashed
  5. prefer a typed mod or JSON file

basics

~20 s

Without --clean, prebuild edits the Podfile already on disk, and the dangerous mod re-inserts its line because nothing checks it is present. Fix it with a guarded, tagged insert or, better, a non-dangerous mod such as withPodfileProperties.

solid answer

~50 s

`withDangerousMod` gives the callback no parsed data: it reads and writes the file itself, and all dangerous mods run before the other mods for that platform. `npx expo prebuild` without `--clean` does not start from the template; it layers onto the existing `ios/` folder, so the regex finds its anchor again in a file that already contains last run's insertion and adds a second copy. Dangerous mods are rarely idempotent unless written to be. I would first ask whether a safer mod exists: `withPodfileProperties` writes `Podfile.properties.json`, which the template Podfile reads, and `withGradleProperties` does the same on Android. If a text edit is unavoidable, I make it idempotent with `CodeGenerator.mergeContents`, which wraps the insertion in tagged `@generated` comments with a content hash, skips it when unchanged, replaces it when changed, and throws when the anchor is missing. Then I run prebuild twice and diff.

code

typescript · 26 lines
typescript
import { CodeGenerator, ConfigPlugin, withDangerousMod } from 'expo/config-plugins';
import fs from 'fs/promises';
import path from 'path';

const withExtraPod: ConfigPlugin = (config) =>
  withDangerousMod(config, [
    'ios',
    async (config) => {
      const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
      const src = await fs.readFile(podfilePath, 'utf8');
      const result = CodeGenerator.mergeContents({
        src,
        newSrc: "  pod 'ExtraSDK', '~> 2.1'",
        tag: 'with-extra-pod',
        anchor: /use_expo_modules!/,
        offset: 1,
        comment: '#',
      });
      if (result.didMerge || result.didClear) {
        await fs.writeFile(podfilePath, result.contents);
      }
      return config;
    },
  ]);

export default withExtraPod;

go deeper

for a junior

Recall that withDangerousMod is the raw-file escape hatch and that Expo recommends typed mod plugins whenever one exists.

for a middle

Explain why prebuild without --clean rereads existing files, and why a regex insert therefore duplicates while setting an object key does not.

for a senior

Show the fix path: a typed or JSON-backed mod first, a tagged mergeContents block otherwise, a loud failure on a missing anchor, and a run-twice test.

for a principal

Treat every dangerous mod as upgrade debt: set a policy on when one is allowed, who re-tests it each SDK, and when to upstream the change instead.

## What a dangerous mod is In Expo's config plugin system most mods are **typed**: the compiler parses a file (Info.plist, entitlements, AndroidManifest.xml, gradle.properties, Podfile.properties.json) into an object, hands it to your callback as `config.modResults`, and serializes it back. Setting a key on an object twice leaves one key, so these mods are naturally repeatable. `withDangerousMod(config, ['ios', async (config) => { ... }])` is the escape hatch. Its callback receives **no file data**. It gets `config.modRequest.platformProjectRoot` and must read, change and write the file itself, usually with string replacement or a regular expression. Expo's docs call these mods dangerous for concrete reasons: - Text edits **do not compose**: if one dangerous mod rewrites the text another uses as an anchor, the second one fails or misfires. - They are **rarely idempotent**: running the same one again may duplicate or corrupt its change. - They are **fragile across SDK upgrades**, because the native template they pattern-match can change with each release. - They run **before the other mods** for that platform, so they cannot see what typed mods will write later. - They are **skipped by introspection**, so `npx expo config --type introspect` cannot preview their effect. ## Why the line is duplicated 1. The first `npx expo prebuild` generates `ios/` from the template. The mod finds `use_expo_modules!` and inserts a `pod` line after it. 2. A later `npx expo prebuild` **without `--clean`** keeps the existing `ios/` folder and layers changes on top. The mod reads the Podfile that already contains its line. 3. The anchor still matches, nothing checks for the earlier insertion, and a second copy is written. `--clean` hides the bug because it deletes the native folders first, so every run starts from the template. That is why a plugin can pass CI, where native folders are usually generated fresh, and still corrupt a developer's working tree. ## Fix 1: avoid the dangerous mod The best fix is not to edit text at all: | Need | Safer mechanism | |---|---| | A value the iOS Podfile should use | `withPodfileProperties` writes `ios/Podfile.properties.json`, which the template Podfile reads as JSON | | A Gradle setting | `withGradleProperties` edits `android/gradle.properties` as a list of items | | An Info.plist, entitlement or manifest change | `withInfoPlist`, `withEntitlementsPlist`, `withAndroidManifest` | | A static Android permission from a library | The library's own manifest, merged by Gradle at build time | Expo's guidance is explicit: prefer static modification, and treat the Podfile, a Ruby file, as unsafe to edit from a plugin. ## Fix 2: make the text edit idempotent When no typed mod exists, make the insertion repeatable. `expo/config-plugins` exports `CodeGenerator.mergeContents`, which: - wraps the new lines between `# @generated begin <tag> - expo prebuild (DO NOT MODIFY) <hash>` and `# @generated end <tag>` comments; - returns the source unchanged, with `didMerge: false`, when that exact header is already present; - removes the old tagged block and inserts the new one when the content, and so the hash, changed; - throws an `ERR_NO_MATCH` error when the anchor is not found, so a template change fails the prebuild loudly instead of silently skipping the edit. A hand-rolled guard (`if (!contents.includes(line))`) also works for a single line, but it cannot update a changed value and says nothing when the anchor disappears. The tag should be unique to the plugin, because another plugin reusing it would remove your block when it merges its own. ## Operating dangerous mods responsibly - Run prebuild **twice in a row** in a test and assert the second run changes nothing. - Test the plugin against each new SDK's template during the beta period, and document the SDK range it supports. - Keep dangerous mods for creating, moving or deleting files, which typed mods cannot do; that also keeps introspection working for everything else. - Do not use `createRunOncePlugin` as the fix: it stops a plugin from being applied twice within one config evaluation, not from re-editing a file that already holds its change. - Log what the mod did, or run with `EXPO_DEBUG=1`, so a reviewer can see which plugin touched the file. - Recommend `npx expo prebuild --clean` when a project's plugins are known not to be idempotent, but do not rely on it as the only protection.

  • Why can a non-idempotent Expo dangerous mod pass CI but break a developer's local project?
    CI and EAS Build usually generate the native folders from scratch, so the mod only ever sees the pristine template. A developer running `npx expo prebuild` without `--clean` keeps the existing folders, so the mod reads its own earlier output and inserts again.
  • What should an Expo dangerous mod do when its regex anchor is not found after an SDK upgrade?
    Fail loudly. A silent skip ships a build missing the native change, which surfaces later as a runtime crash or missing capability. `CodeGenerator.mergeContents` already throws an `ERR_NO_MATCH` error; a hand-written mod should throw or at least warn clearly, and the plugin should state which SDK versions it supports.
  • Why does an Expo dangerous mod not show up in npx expo config --type introspect output?
    Introspection runs only mods whose base mod is marked introspective, meaning it can compute results without writing files. Dangerous mods do their own file I/O, so the compiler removes them in introspection mode, and their effect is visible only after a real prebuild.

A standard mod is filling in a named field on a form: fill it twice and the field still holds one value. A dangerous mod is find-and-replace on a printed letter: run it twice and the sentence appears twice, unless you first check whether it is already there.

saying these in an interview costs you the question

  • Prebuild always regenerates native folders, so a regex insert cannot duplicate.
  • Wrapping the plugin in createRunOncePlugin makes its file edits idempotent.
  • withDangerousMod runs last, after every typed mod has written its file.
  • Silently skipping the edit when the anchor is missing is the safe default.
  • Editing Podfile text by regex is the recommended way to configure CocoaPods.